@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.
- package/CHANGELOG.md +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/pack list|add <url>|info <name>|remove <name>|use <pack>/<sound>` and
|
|
3
|
+
* `/kit [bank]`: Strudel-style sample packs on dawg tracks.
|
|
4
|
+
*
|
|
5
|
+
* The score edits here are pure and return `ScoreOperation`s, so the window
|
|
6
|
+
* (`src/main.ts`) and the agent tools (`use_sound`) share them. Fetching and
|
|
7
|
+
* pinning live in `src/audio/packs.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
SAMPLER_INSTRUMENT,
|
|
11
|
+
isSamplerInstrument,
|
|
12
|
+
parsePackRef,
|
|
13
|
+
samplerVoiceSlots,
|
|
14
|
+
type Sampler,
|
|
15
|
+
type SampleRef,
|
|
16
|
+
type ScoreOperation,
|
|
17
|
+
type TrackScore,
|
|
18
|
+
} from "../../core/score.ts";
|
|
19
|
+
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
20
|
+
import {
|
|
21
|
+
ALIASED_PACK,
|
|
22
|
+
DEFAULT_KIT,
|
|
23
|
+
DEFAULT_KITS,
|
|
24
|
+
PackError,
|
|
25
|
+
kitFromBank,
|
|
26
|
+
pinInstrument,
|
|
27
|
+
pinKit,
|
|
28
|
+
type BankAliases,
|
|
29
|
+
type PackInfo,
|
|
30
|
+
type PackStore,
|
|
31
|
+
} from "../audio/packs.ts";
|
|
32
|
+
import { formatBytes, parseByteSize, type CacheUsage } from "../audio/cache.ts";
|
|
33
|
+
|
|
34
|
+
export type PackCommand =
|
|
35
|
+
| Readonly<{ kind: "list" }>
|
|
36
|
+
| Readonly<{ kind: "add"; source: string; name?: string }>
|
|
37
|
+
| Readonly<{ kind: "info"; name: string }>
|
|
38
|
+
| Readonly<{ kind: "remove"; name: string }>
|
|
39
|
+
| Readonly<{ kind: "use"; sound: string; voice?: string }>
|
|
40
|
+
/** Show cache usage; with `pruneTo`, evict down to that many bytes. */
|
|
41
|
+
| Readonly<{ kind: "cache"; pruneTo?: number | "cap" }>
|
|
42
|
+
| Readonly<{ kind: "usage"; message: string }>;
|
|
43
|
+
|
|
44
|
+
export const PACK_USAGE =
|
|
45
|
+
"/pack list · /pack add <url|github:user/repo[/branch]> [as <name>] · /pack info <name> · /pack remove <name> · /pack use <pack>/<sound>[:<n>] [as <voice>] · /pack cache [prune [size]|clear]";
|
|
46
|
+
|
|
47
|
+
/** Parses `/pack …`; undefined when the command is not `/pack`. */
|
|
48
|
+
export function parsePackCommand(command: string): PackCommand | undefined {
|
|
49
|
+
const match = command.trim().match(/^\/packs?(?:\s+(.*))?$/i);
|
|
50
|
+
if (!match) return undefined;
|
|
51
|
+
const words = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
52
|
+
const verb = (words[0] ?? "list").toLowerCase();
|
|
53
|
+
const rest = words.slice(1);
|
|
54
|
+
const named = (): { value?: string; as?: string } => {
|
|
55
|
+
const at = rest.findIndex((word) => word.toLowerCase() === "as");
|
|
56
|
+
if (at === -1) return rest[0] ? { value: rest[0] } : {};
|
|
57
|
+
return {
|
|
58
|
+
...(rest[0] && at > 0 ? { value: rest[0] } : {}),
|
|
59
|
+
...(rest[at + 1] ? { as: rest[at + 1] } : {}),
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
if (verb === "list" || verb === "ls") return { kind: "list" };
|
|
63
|
+
if (verb === "add") {
|
|
64
|
+
const { value, as } = named();
|
|
65
|
+
if (!value)
|
|
66
|
+
return {
|
|
67
|
+
kind: "usage",
|
|
68
|
+
message: `pack · add needs a manifest URL · ${PACK_USAGE}`,
|
|
69
|
+
};
|
|
70
|
+
return as
|
|
71
|
+
? { kind: "add", source: value, name: as }
|
|
72
|
+
: { kind: "add", source: value };
|
|
73
|
+
}
|
|
74
|
+
if (verb === "info" || verb === "show") {
|
|
75
|
+
if (!rest[0])
|
|
76
|
+
return {
|
|
77
|
+
kind: "usage",
|
|
78
|
+
message: `pack · info needs a pack name · ${PACK_USAGE}`,
|
|
79
|
+
};
|
|
80
|
+
return { kind: "info", name: rest[0] };
|
|
81
|
+
}
|
|
82
|
+
if (verb === "remove" || verb === "rm") {
|
|
83
|
+
if (!rest[0])
|
|
84
|
+
return {
|
|
85
|
+
kind: "usage",
|
|
86
|
+
message: `pack · remove needs a pack name · ${PACK_USAGE}`,
|
|
87
|
+
};
|
|
88
|
+
return { kind: "remove", name: rest[0] };
|
|
89
|
+
}
|
|
90
|
+
if (verb === "cache") {
|
|
91
|
+
const action = (rest[0] ?? "").toLowerCase();
|
|
92
|
+
if (!action) return { kind: "cache" };
|
|
93
|
+
if (action === "clear") return { kind: "cache", pruneTo: 0 };
|
|
94
|
+
if (action === "prune") {
|
|
95
|
+
if (!rest[1]) return { kind: "cache", pruneTo: "cap" };
|
|
96
|
+
const bytes = parseByteSize(rest[1]);
|
|
97
|
+
if (bytes !== undefined) return { kind: "cache", pruneTo: bytes };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: "usage",
|
|
101
|
+
message:
|
|
102
|
+
"pack · cache shows usage · /pack cache prune [size, e.g. 500M] · /pack cache clear (keeps this project's sounds)",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (verb === "use") {
|
|
106
|
+
const { value, as } = named();
|
|
107
|
+
if (!value)
|
|
108
|
+
return {
|
|
109
|
+
kind: "usage",
|
|
110
|
+
message: `pack · use needs <pack>/<sound> · ${PACK_USAGE}`,
|
|
111
|
+
};
|
|
112
|
+
return as
|
|
113
|
+
? { kind: "use", sound: value, voice: as }
|
|
114
|
+
: { kind: "use", sound: value };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
kind: "usage",
|
|
118
|
+
message: `pack · unknown ${verb.slice(0, 20)} · ${PACK_USAGE}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type KitCommand =
|
|
123
|
+
Readonly<{ kind: "set"; bank: string }> | Readonly<{ kind: "list" }>;
|
|
124
|
+
|
|
125
|
+
/** `/kit [bank]` (default kit) and `/kit list`; undefined for other commands. */
|
|
126
|
+
export function parseKitCommand(command: string): KitCommand | undefined {
|
|
127
|
+
const match = command.trim().match(/^\/kit(?:\s+(\S+))?\s*$/i);
|
|
128
|
+
if (!match) return undefined;
|
|
129
|
+
const bank = match[1];
|
|
130
|
+
if (bank && /^(list|ls)$/i.test(bank)) return { kind: "list" };
|
|
131
|
+
return { kind: "set", bank: bank ?? DEFAULT_KIT };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `pack:x/y`, `x/y`, `x/y:3` → `pack:x/y[:n]`; undefined when malformed. */
|
|
135
|
+
export function normalizeSoundRef(value: string): string | undefined {
|
|
136
|
+
const src = value.startsWith("pack:") ? value : `pack:${value}`;
|
|
137
|
+
return parsePackRef(src) ? src : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Score edits
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Operations that give `trackId` the sampler `next`, keeping its hits on the
|
|
145
|
+
* same sound where the new sampler has one: drum tracks map GM pitches to
|
|
146
|
+
* drum voice names (kick, snare, …), oneshot samplers map by voice name.
|
|
147
|
+
* Creates the track when it does not exist.
|
|
148
|
+
*/
|
|
149
|
+
export function samplerOperations(
|
|
150
|
+
score: TrackScore,
|
|
151
|
+
trackId: string,
|
|
152
|
+
next: Sampler,
|
|
153
|
+
name?: string,
|
|
154
|
+
): ScoreOperation[] {
|
|
155
|
+
const track = score.tracks.find((item) => item.id === trackId);
|
|
156
|
+
if (!track)
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
type: "addTrack",
|
|
160
|
+
track: {
|
|
161
|
+
id: trackId,
|
|
162
|
+
name: name ?? trackId,
|
|
163
|
+
instrument: SAMPLER_INSTRUMENT,
|
|
164
|
+
sampler: next,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const operations: ScoreOperation[] = [
|
|
169
|
+
{
|
|
170
|
+
type: "updateTrack",
|
|
171
|
+
trackId,
|
|
172
|
+
// A synth kit name does not apply to a sampler track.
|
|
173
|
+
patch: {
|
|
174
|
+
instrument: SAMPLER_INSTRUMENT,
|
|
175
|
+
sampler: next,
|
|
176
|
+
...(track.kit ? { kit: null } : {}),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
if (next.mode !== "oneshot") return operations;
|
|
181
|
+
const newSlots = samplerVoiceSlots(next);
|
|
182
|
+
let voiceAt: (pitch: number) => string | undefined;
|
|
183
|
+
if (isDrumInstrument(track.instrument))
|
|
184
|
+
voiceAt = (pitch) => drumVoiceForPitch(pitch);
|
|
185
|
+
else if (
|
|
186
|
+
isSamplerInstrument(track.instrument) &&
|
|
187
|
+
track.sampler?.mode === "oneshot"
|
|
188
|
+
) {
|
|
189
|
+
const byPitch = new Map<number, string>();
|
|
190
|
+
for (const [voice, slot] of samplerVoiceSlots(track.sampler))
|
|
191
|
+
byPitch.set(slot, voice);
|
|
192
|
+
voiceAt = (pitch) => byPitch.get(pitch);
|
|
193
|
+
} else return operations;
|
|
194
|
+
for (const note of score.notes) {
|
|
195
|
+
if (note.trackId !== trackId) continue;
|
|
196
|
+
const voice = voiceAt(note.pitch);
|
|
197
|
+
const slot = voice === undefined ? undefined : newSlots.get(voice);
|
|
198
|
+
if (slot !== undefined && slot !== note.pitch)
|
|
199
|
+
operations.push({
|
|
200
|
+
type: "updateNote",
|
|
201
|
+
noteId: note.id,
|
|
202
|
+
patch: { pitch: slot },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return operations;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Where `/kit` lands: the focused track when it is a drum kit, a sampler or
|
|
210
|
+
* empty; otherwise undefined (a melodic track keeps its notes).
|
|
211
|
+
*/
|
|
212
|
+
export function kitTarget(
|
|
213
|
+
score: TrackScore,
|
|
214
|
+
focusedId: string,
|
|
215
|
+
): string | undefined {
|
|
216
|
+
const track = score.tracks.find((item) => item.id === focusedId);
|
|
217
|
+
if (!track) return focusedId;
|
|
218
|
+
if (
|
|
219
|
+
isDrumInstrument(track.instrument) ||
|
|
220
|
+
isSamplerInstrument(track.instrument)
|
|
221
|
+
)
|
|
222
|
+
return focusedId;
|
|
223
|
+
return score.notes.some((note) => note.trackId === focusedId)
|
|
224
|
+
? undefined
|
|
225
|
+
: focusedId;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The focused sampler's voices plus `voice` → `ref` (oneshot). */
|
|
229
|
+
export function withVoice(
|
|
230
|
+
score: TrackScore,
|
|
231
|
+
trackId: string,
|
|
232
|
+
voice: string,
|
|
233
|
+
ref: SampleRef,
|
|
234
|
+
): Sampler {
|
|
235
|
+
const track = score.tracks.find((item) => item.id === trackId);
|
|
236
|
+
const previous =
|
|
237
|
+
track &&
|
|
238
|
+
isSamplerInstrument(track.instrument) &&
|
|
239
|
+
track.sampler?.mode === "oneshot"
|
|
240
|
+
? track.sampler
|
|
241
|
+
: undefined;
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
...(previous ?? {}),
|
|
244
|
+
mode: "oneshot",
|
|
245
|
+
voices: Object.freeze({ ...(previous?.voices ?? {}), [voice]: ref }),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A sampler voice name from a sound (`RolandTR909_bd:2` → `bd_2`). */
|
|
250
|
+
export function voiceNameForSound(sound: string, n: number | string): string {
|
|
251
|
+
const base = (
|
|
252
|
+
sound.includes("_") ? sound.slice(sound.lastIndexOf("_") + 1) : sound
|
|
253
|
+
)
|
|
254
|
+
.toLowerCase()
|
|
255
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
256
|
+
.replace(/^[^a-z]+/, "")
|
|
257
|
+
.slice(0, 24);
|
|
258
|
+
const stem = base || "sound";
|
|
259
|
+
return n === 0
|
|
260
|
+
? stem
|
|
261
|
+
: `${stem}_${String(n)
|
|
262
|
+
.toLowerCase()
|
|
263
|
+
.replace(/[^a-z0-9]/g, "")}`.slice(0, 32);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Use a sound (window and agent)
|
|
268
|
+
|
|
269
|
+
export type UseSoundResult = Readonly<{
|
|
270
|
+
operations: ScoreOperation[];
|
|
271
|
+
trackId: string;
|
|
272
|
+
summary: string;
|
|
273
|
+
/** Packs whose sounds the edit pins (for credits). */
|
|
274
|
+
license: string;
|
|
275
|
+
}>;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Pins a pack sound onto a track. A keyed sound (note zones, e.g.
|
|
279
|
+
* `gm/gm_acoustic_grand_piano`, `piano/piano`) makes the track a keyed
|
|
280
|
+
* instrument; a bank name (`tidal-drum-machines/RolandTR909`, or a `/kit`
|
|
281
|
+
* name) makes it a drum kit; anything else adds one oneshot voice.
|
|
282
|
+
*/
|
|
283
|
+
export async function useSound(
|
|
284
|
+
store: PackStore,
|
|
285
|
+
score: TrackScore,
|
|
286
|
+
trackId: string,
|
|
287
|
+
sound: string,
|
|
288
|
+
options: Readonly<{
|
|
289
|
+
voice?: string;
|
|
290
|
+
mode?: "auto" | "keyed" | "oneshot";
|
|
291
|
+
}> = {},
|
|
292
|
+
): Promise<UseSoundResult> {
|
|
293
|
+
const kitName = sound.replace(/^pack:/, "");
|
|
294
|
+
if (!kitName.includes("/") || DEFAULT_KITS[kitName.toLowerCase()])
|
|
295
|
+
return useKit(store, score, trackId, kitName);
|
|
296
|
+
const src = normalizeSoundRef(sound);
|
|
297
|
+
const ref = src ? parsePackRef(src) : undefined;
|
|
298
|
+
if (!ref)
|
|
299
|
+
throw new PackError(`${sound.slice(0, 80)} · use <pack>/<sound>[:<n>]`);
|
|
300
|
+
const manifest = await store.manifest(ref.pack);
|
|
301
|
+
const key = await store.soundKey(ref.pack, manifest, ref.sound);
|
|
302
|
+
const entry = key === undefined ? undefined : manifest.sounds.get(key);
|
|
303
|
+
if (!entry || key === undefined) {
|
|
304
|
+
const bank = await store.bankKey(ref.pack, manifest, ref.sound);
|
|
305
|
+
if (bank) return useKit(store, score, trackId, bank, ref.pack);
|
|
306
|
+
throw new PackError(
|
|
307
|
+
`${ref.pack} has no sound ${ref.sound} · search_sounds or /pack info ${ref.pack}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const pack = (await store.info(ref.pack)) as PackInfo;
|
|
311
|
+
const keyed =
|
|
312
|
+
options.mode === "keyed" ||
|
|
313
|
+
(options.mode !== "oneshot" && entry.kind === "zones");
|
|
314
|
+
if (keyed) {
|
|
315
|
+
const sampler = await pinInstrument(ref.pack, key, store);
|
|
316
|
+
return {
|
|
317
|
+
operations: samplerOperations(score, trackId, sampler, key),
|
|
318
|
+
trackId,
|
|
319
|
+
summary: `${trackId} plays ${ref.pack}/${key} · ${Object.keys(sampler.voices).length} zones · ${pack.license}`,
|
|
320
|
+
license: pack.license,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const pinned = await store.pin(ref);
|
|
324
|
+
const voice = options.voice ?? voiceNameForSound(ref.sound, ref.n);
|
|
325
|
+
if (!/^[a-z][a-z0-9_]{0,31}$/.test(voice))
|
|
326
|
+
throw new PackError(
|
|
327
|
+
`voice "${voice.slice(0, 40)}" is not a valid name · use a-z, 0-9 and _`,
|
|
328
|
+
);
|
|
329
|
+
const sampler = withVoice(score, trackId, voice, pinned);
|
|
330
|
+
return {
|
|
331
|
+
operations: samplerOperations(score, trackId, sampler),
|
|
332
|
+
trackId,
|
|
333
|
+
summary: `${voice} on ${trackId} · ${pinned.src} · ${pack.license}`,
|
|
334
|
+
license: pack.license,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `/kit <bank>` on a track: pins one oneshot voice per drum voice. */
|
|
339
|
+
export async function useKit(
|
|
340
|
+
store: PackStore,
|
|
341
|
+
score: TrackScore,
|
|
342
|
+
trackId: string,
|
|
343
|
+
bank: string,
|
|
344
|
+
packName?: string,
|
|
345
|
+
): Promise<UseSoundResult> {
|
|
346
|
+
const kit = await kitFromBank(
|
|
347
|
+
bank,
|
|
348
|
+
store,
|
|
349
|
+
packName ? { pack: packName } : {},
|
|
350
|
+
);
|
|
351
|
+
if (!kit || (packName && kit.pack !== packName))
|
|
352
|
+
throw new PackError(
|
|
353
|
+
`no kit ${bank.slice(0, 40)} · /kit list shows banks and nicknames (909, TR808, sp12, RolandTR909, …)`,
|
|
354
|
+
);
|
|
355
|
+
const sampler = await pinKit(kit, store);
|
|
356
|
+
const missing = ["kick", "snare", "hat"].filter(
|
|
357
|
+
(voice) => !kit.voices.has(voice as never),
|
|
358
|
+
);
|
|
359
|
+
return {
|
|
360
|
+
operations: samplerOperations(score, trackId, sampler, "drums"),
|
|
361
|
+
trackId,
|
|
362
|
+
summary: `kit ${kit.bank || kit.pack} on ${trackId} · ${kit.voices.size} voices${missing.length ? ` · no ${missing.join("/")}` : ""} · ${kit.pack} (${kit.license})`,
|
|
363
|
+
license: kit.license,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Lines for `/kit list`: dawg's short names, then every Strudel bank
|
|
369
|
+
* nickname (`TR909 · RolandTR909`), then a hint for full bank names.
|
|
370
|
+
*/
|
|
371
|
+
export function kitListLines(aliases: BankAliases): string[] {
|
|
372
|
+
const lines = Object.entries(DEFAULT_KITS).map(
|
|
373
|
+
([name, kit]) => `${name} · ${kit.bank || "(whole pack)"} · ${kit.pack}`,
|
|
374
|
+
);
|
|
375
|
+
const nicknames = [...aliases.nicknames].sort(([, a], [, b]) =>
|
|
376
|
+
a.toLowerCase() < b.toLowerCase() ? -1 : 1,
|
|
377
|
+
);
|
|
378
|
+
if (nicknames.length) {
|
|
379
|
+
lines.push(`Strudel nicknames (${nicknames.length}, any case):`);
|
|
380
|
+
for (const [bank, nickname] of nicknames)
|
|
381
|
+
lines.push(`${nickname} · ${bank} · ${ALIASED_PACK}`);
|
|
382
|
+
}
|
|
383
|
+
lines.push(
|
|
384
|
+
"any bank of a pack also works: /kit RolandTR727, /kit AkaiMPC60, …",
|
|
385
|
+
);
|
|
386
|
+
return lines;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export type CacheReport = Readonly<{
|
|
390
|
+
packs: CacheUsage & { max: number };
|
|
391
|
+
assets: CacheUsage & { max: number };
|
|
392
|
+
freed?: Readonly<{ files: number; bytes: number }>;
|
|
393
|
+
}>;
|
|
394
|
+
|
|
395
|
+
/** Lines for `/pack cache`. */
|
|
396
|
+
export function cacheLines(report: CacheReport): string[] {
|
|
397
|
+
const row = (
|
|
398
|
+
label: string,
|
|
399
|
+
usage: CacheUsage & { max: number },
|
|
400
|
+
env: string,
|
|
401
|
+
) =>
|
|
402
|
+
`${label} · ${formatBytes(usage.bytes)} of ${formatBytes(usage.max)} · ${usage.files} file${usage.files === 1 ? "" : "s"} · ${env}`;
|
|
403
|
+
return [
|
|
404
|
+
...(report.freed
|
|
405
|
+
? [
|
|
406
|
+
`pruned · ${report.freed.files} file${report.freed.files === 1 ? "" : "s"} · ${formatBytes(report.freed.bytes)} freed`,
|
|
407
|
+
]
|
|
408
|
+
: []),
|
|
409
|
+
row("pack downloads", report.packs, "DAWG_PACKS_CACHE_MAX"),
|
|
410
|
+
row("decoded audio (this project)", report.assets, "DAWG_ASSETS_CACHE_MAX"),
|
|
411
|
+
"least recently used files go first; this project's sounds are never evicted",
|
|
412
|
+
"evicted sounds re-fetch by their pinned sha256 on next use",
|
|
413
|
+
"/pack cache prune [size] · /pack cache clear",
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Lines for `/pack list`. */
|
|
418
|
+
export function packListLines(packs: readonly PackInfo[]): string[] {
|
|
419
|
+
return packs.map(
|
|
420
|
+
(pack) =>
|
|
421
|
+
`${pack.name} · ${pack.license} · ${pack.title}${pack.builtin ? "" : ` · ${pack.source}`}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt grammar for generated rhythm rows (`core/euclid.ts`). Every
|
|
3
|
+
* command is one next score, so one revision and one undo step; the
|
|
4
|
+
* Euclidean editor (`src/tui/euclid.ts`) emits these same lines.
|
|
5
|
+
*
|
|
6
|
+
* euclid <voice> <pulses> [<steps>] [<field> <value> ...]
|
|
7
|
+
* euclid <voice> <field> <value> [<field> <value> ...]
|
|
8
|
+
* euclid <voice> off | freeze remove the row (freeze keeps notes)
|
|
9
|
+
* grid <voice> <steps> explicit steps: x hit, X accent, . rest
|
|
10
|
+
*
|
|
11
|
+
* Fields: steps, pulses, rotate (rot), division (div), repeats (rep), time,
|
|
12
|
+
* pace, ramp, velocity (vel), accent, accents, gate, legato on|off,
|
|
13
|
+
* probability (prob), seed, swing, nudge. `default` resets a field. Named
|
|
14
|
+
* fields merge into the voice's existing row, so `euclid hat rotate 2`
|
|
15
|
+
* only rotates.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
RHYTHM_DEFAULTS,
|
|
19
|
+
canonicalDivision,
|
|
20
|
+
normalizeRhythmRow,
|
|
21
|
+
rowSummary,
|
|
22
|
+
type RhythmRow,
|
|
23
|
+
} from "../../core/euclid.ts";
|
|
24
|
+
import {
|
|
25
|
+
removeRhythmRow,
|
|
26
|
+
rhythmVoicePitch,
|
|
27
|
+
setRhythmRow,
|
|
28
|
+
} from "../../core/rhythm.ts";
|
|
29
|
+
import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
|
|
30
|
+
|
|
31
|
+
export type RhythmCommand =
|
|
32
|
+
| Readonly<{
|
|
33
|
+
type: "rhythm-set";
|
|
34
|
+
voice: string;
|
|
35
|
+
/** Field values to merge; `null` resets a field to its default. */
|
|
36
|
+
fields: Readonly<Record<string, number | string | boolean | null>>;
|
|
37
|
+
}>
|
|
38
|
+
| Readonly<{ type: "rhythm-remove"; voice: string; keepNotes: boolean }>;
|
|
39
|
+
|
|
40
|
+
const FIELD_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
|
41
|
+
steps: "steps",
|
|
42
|
+
pulses: "pulses",
|
|
43
|
+
hits: "pulses",
|
|
44
|
+
rotate: "rotate",
|
|
45
|
+
rot: "rotate",
|
|
46
|
+
division: "division",
|
|
47
|
+
div: "division",
|
|
48
|
+
repeats: "repeats",
|
|
49
|
+
rep: "repeats",
|
|
50
|
+
time: "time",
|
|
51
|
+
pace: "pace",
|
|
52
|
+
ramp: "ramp",
|
|
53
|
+
velocity: "velocity",
|
|
54
|
+
vel: "velocity",
|
|
55
|
+
accent: "accent",
|
|
56
|
+
accents: "accents",
|
|
57
|
+
gate: "gate",
|
|
58
|
+
sustain: "gate",
|
|
59
|
+
legato: "legato",
|
|
60
|
+
probability: "probability",
|
|
61
|
+
prob: "probability",
|
|
62
|
+
seed: "seed",
|
|
63
|
+
swing: "swing",
|
|
64
|
+
nudge: "nudge",
|
|
65
|
+
grid: "grid",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const DIVISION_FIELDS = new Set(["division", "time"]);
|
|
69
|
+
const NUMBER = /^-?\d+(?:\.\d+)?$/;
|
|
70
|
+
const VOICE = /^[A-Za-z0-9#_-]{1,32}$/;
|
|
71
|
+
|
|
72
|
+
/** Parses one rhythm command, or undefined when `prompt` is not one. */
|
|
73
|
+
export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
|
|
74
|
+
const text = prompt.trim().replace(/\s+/g, " ");
|
|
75
|
+
if (text.length > 512) return undefined;
|
|
76
|
+
const grid = text.match(/^grid ([A-Za-z0-9#_-]{1,32}) ([xX.\-]{1,64})$/i);
|
|
77
|
+
if (grid)
|
|
78
|
+
return {
|
|
79
|
+
type: "rhythm-set",
|
|
80
|
+
voice: grid[1]!.toLowerCase(),
|
|
81
|
+
fields: { grid: grid[2]! },
|
|
82
|
+
};
|
|
83
|
+
const head = text.match(/^euclid ([A-Za-z0-9#_-]{1,32})(?: (.*))?$/i);
|
|
84
|
+
if (!head) return undefined;
|
|
85
|
+
const voice = head[1]!;
|
|
86
|
+
if (!VOICE.test(voice)) return undefined;
|
|
87
|
+
const tokens = (head[2] ?? "").split(" ").filter((token) => token !== "");
|
|
88
|
+
if (tokens.length === 1 && /^(off|clear|remove)$/i.test(tokens[0]!))
|
|
89
|
+
return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: false };
|
|
90
|
+
if (tokens.length === 1 && /^freeze$/i.test(tokens[0]!))
|
|
91
|
+
return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: true };
|
|
92
|
+
const fields: Record<string, number | string | boolean | null> = {};
|
|
93
|
+
let index = 0;
|
|
94
|
+
if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
|
|
95
|
+
fields.pulses = Number(tokens[index]);
|
|
96
|
+
index += 1;
|
|
97
|
+
if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
|
|
98
|
+
fields.steps = Number(tokens[index]);
|
|
99
|
+
index += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
while (index < tokens.length) {
|
|
103
|
+
const key = FIELD_ALIASES[tokens[index]!.toLowerCase()];
|
|
104
|
+
const raw = tokens[index + 1];
|
|
105
|
+
if (!key || raw === undefined) return undefined;
|
|
106
|
+
index += 2;
|
|
107
|
+
if (/^default$/i.test(raw)) fields[key] = null;
|
|
108
|
+
else if (key === "legato") {
|
|
109
|
+
if (!/^(on|off|true|false)$/i.test(raw)) return undefined;
|
|
110
|
+
fields[key] = /^(on|true)$/i.test(raw);
|
|
111
|
+
} else if (DIVISION_FIELDS.has(key)) {
|
|
112
|
+
const division = canonicalDivision(raw);
|
|
113
|
+
if (!division) return undefined;
|
|
114
|
+
fields[key] = division;
|
|
115
|
+
} else if (key === "grid") {
|
|
116
|
+
if (!/^[xX.\-]{1,64}$/.test(raw)) return undefined;
|
|
117
|
+
fields[key] = raw;
|
|
118
|
+
} else {
|
|
119
|
+
if (!NUMBER.test(raw)) return undefined;
|
|
120
|
+
fields[key] = Number(raw);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { type: "rhythm-set", voice: lowerDrum(voice), fields };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pitch voices keep their case (`C#2`); drum and sampler names are lower-case. */
|
|
127
|
+
function lowerDrum(voice: string): string {
|
|
128
|
+
return /^[A-Ga-g][#b]?-?\d$/.test(voice) ? voice : voice.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type RhythmResult = Readonly<{
|
|
132
|
+
ok: boolean;
|
|
133
|
+
message: string;
|
|
134
|
+
next?: TrackScore;
|
|
135
|
+
kind?: string;
|
|
136
|
+
payload?: Record<string, unknown>;
|
|
137
|
+
}>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The row `command` produces from `current` (the voice's existing row, if
|
|
141
|
+
* any). Throws `Error` naming the field when the result is invalid.
|
|
142
|
+
*/
|
|
143
|
+
export function mergeRhythmRow(
|
|
144
|
+
current: RhythmRow | undefined,
|
|
145
|
+
voice: string,
|
|
146
|
+
fields: Readonly<Record<string, number | string | boolean | null>>,
|
|
147
|
+
): RhythmRow {
|
|
148
|
+
const merged: Record<string, unknown> = { ...(current ?? { voice }) };
|
|
149
|
+
merged.voice = voice;
|
|
150
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
151
|
+
if (value === null) delete merged[key];
|
|
152
|
+
else merged[key] = value;
|
|
153
|
+
}
|
|
154
|
+
// Naming pulses or steps switches a grid row back to a Euclidean one.
|
|
155
|
+
if ("pulses" in fields || "steps" in fields)
|
|
156
|
+
if (!("grid" in fields)) delete merged.grid;
|
|
157
|
+
if ("grid" in fields && fields.grid !== null) {
|
|
158
|
+
delete merged.steps;
|
|
159
|
+
delete merged.pulses;
|
|
160
|
+
delete merged.rotate;
|
|
161
|
+
}
|
|
162
|
+
// Keep pulses inside a shortened row instead of rejecting the edit.
|
|
163
|
+
if (merged.grid === undefined) {
|
|
164
|
+
const steps =
|
|
165
|
+
typeof merged.steps === "number" ? merged.steps : RHYTHM_DEFAULTS.steps;
|
|
166
|
+
const pulses =
|
|
167
|
+
typeof merged.pulses === "number"
|
|
168
|
+
? merged.pulses
|
|
169
|
+
: RHYTHM_DEFAULTS.pulses;
|
|
170
|
+
if (!("pulses" in fields) && pulses > steps) merged.pulses = steps;
|
|
171
|
+
}
|
|
172
|
+
return normalizeRhythmRow(merged, voice);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Applies `command` to `trackId`; one next score, one session event. */
|
|
176
|
+
export function applyRhythmCommand(
|
|
177
|
+
score: TrackScore,
|
|
178
|
+
trackId: string,
|
|
179
|
+
command: RhythmCommand,
|
|
180
|
+
): RhythmResult {
|
|
181
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
182
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
183
|
+
const existing = (track.rhythm ?? []).find(
|
|
184
|
+
(row) => row.voice === command.voice,
|
|
185
|
+
);
|
|
186
|
+
if (command.type === "rhythm-remove") {
|
|
187
|
+
if (!existing)
|
|
188
|
+
return { ok: false, message: `no ${command.voice} row on ${trackId}` };
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
message: `euclid · ${command.voice} ${command.keepNotes ? "frozen" : "off"}`,
|
|
192
|
+
next: removeRhythmRow(score, trackId, command.voice, command.keepNotes),
|
|
193
|
+
kind: "score.rhythm",
|
|
194
|
+
payload: {
|
|
195
|
+
trackId,
|
|
196
|
+
voice: command.voice,
|
|
197
|
+
remove: true,
|
|
198
|
+
keepNotes: command.keepNotes,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (rhythmVoicePitch(track, command.voice) === undefined)
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
message: `${command.voice} is not a voice on ${trackId} · try instrument kit`,
|
|
206
|
+
};
|
|
207
|
+
let row: RhythmRow;
|
|
208
|
+
try {
|
|
209
|
+
row = mergeRhythmRow(existing, command.voice, command.fields);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
message: `euclid · ${error instanceof Error ? error.message : String(error)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const next = setRhythmRow(score, trackId, row);
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
message: `euclid · ${row.voice} ${rowSummary(row)}`,
|
|
221
|
+
next,
|
|
222
|
+
kind: "score.rhythm",
|
|
223
|
+
payload: { trackId, row },
|
|
224
|
+
};
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error instanceof ScoreValidationError)
|
|
227
|
+
return { ok: false, message: `euclid · ${error.message}` };
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|