@hraness/dawg 0.2.0 → 0.4.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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -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 +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- 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 +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/commands/music.ts
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
createScore,
|
|
27
27
|
updateTrack,
|
|
28
28
|
setTrackAutomation,
|
|
29
|
-
|
|
29
|
+
automationPoints,
|
|
30
|
+
automationRange,
|
|
31
|
+
isAutomationParameter,
|
|
30
32
|
type AutomationParameter,
|
|
31
33
|
type NoteInput,
|
|
32
34
|
type TrackScore,
|
|
@@ -96,12 +98,17 @@ const LANE_ALIASES: Readonly<Record<string, EffectAutomationParameter>> =
|
|
|
96
98
|
export function parseEffectLane(
|
|
97
99
|
name: string,
|
|
98
100
|
): EffectAutomationParameter | undefined {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
if (Object.prototype.hasOwnProperty.call(LANE_ALIASES, name))
|
|
102
|
+
return LANE_ALIASES[name];
|
|
103
|
+
// `fx` lanes by name: `distort-drive`, `autofilter-cutoff`, `reverb-mix`.
|
|
104
|
+
return isAutomationParameter(name) && name !== "volume" && name !== "pan"
|
|
105
|
+
? name
|
|
101
106
|
: undefined;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export type MusicResult = Readonly<{
|
|
110
|
+
/** `true` when the command applied; `false` carries a failure message. */
|
|
111
|
+
ok: boolean;
|
|
105
112
|
message: string;
|
|
106
113
|
next?: TrackScore;
|
|
107
114
|
kind?: string;
|
|
@@ -246,7 +253,7 @@ export function parseMusicCommand(prompt: string): MusicCommand | undefined {
|
|
|
246
253
|
const beat = Number(automate[2]);
|
|
247
254
|
const value = Number(automate[3]);
|
|
248
255
|
if (!parameter || !validBeat(beat)) return undefined;
|
|
249
|
-
const { min, max } =
|
|
256
|
+
const { min, max } = automationRange(parameter);
|
|
250
257
|
if (!Number.isFinite(value) || value < min || value > max) return undefined;
|
|
251
258
|
return { type: "effect-automation", parameter, beat, value };
|
|
252
259
|
}
|
|
@@ -264,11 +271,12 @@ export function applyMusicCommand(
|
|
|
264
271
|
newId: (index: number) => string,
|
|
265
272
|
): MusicResult {
|
|
266
273
|
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
267
|
-
if (!track) return { message: `no track · ${trackId}` };
|
|
274
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
268
275
|
|
|
269
276
|
if (command.type === "solo") {
|
|
270
277
|
const next = updateTrack(score, trackId, { solo: command.solo });
|
|
271
278
|
return {
|
|
279
|
+
ok: true,
|
|
272
280
|
message: `${command.solo ? "solo" : "unsolo"} · ${trackId}`,
|
|
273
281
|
next,
|
|
274
282
|
kind: "score.track",
|
|
@@ -281,6 +289,7 @@ export function applyMusicCommand(
|
|
|
281
289
|
? { cutoff: command.cutoff, resonance: command.resonance }
|
|
282
290
|
: null;
|
|
283
291
|
return {
|
|
292
|
+
ok: true,
|
|
284
293
|
message: filter
|
|
285
294
|
? `filter · ${filter.cutoff} Hz res ${filter.resonance}`
|
|
286
295
|
: "filter · off",
|
|
@@ -295,6 +304,7 @@ export function applyMusicCommand(
|
|
|
295
304
|
? { beats: command.beats, feedback: command.feedback, mix: command.mix }
|
|
296
305
|
: null;
|
|
297
306
|
return {
|
|
307
|
+
ok: true,
|
|
298
308
|
message: delay
|
|
299
309
|
? `delay · ${delay.beats} beats fb ${delay.feedback} mix ${delay.mix}`
|
|
300
310
|
: "delay · off",
|
|
@@ -309,6 +319,7 @@ export function applyMusicCommand(
|
|
|
309
319
|
? { mix: command.mix, size: command.size }
|
|
310
320
|
: null;
|
|
311
321
|
return {
|
|
322
|
+
ok: true,
|
|
312
323
|
message: reverb
|
|
313
324
|
? `reverb · mix ${reverb.mix} size ${reverb.size}`
|
|
314
325
|
: "reverb · off",
|
|
@@ -326,17 +337,15 @@ export function applyMusicCommand(
|
|
|
326
337
|
if (command.type === "effect-automation") {
|
|
327
338
|
const tick = Math.round(command.beat * score.ticksPerBeat);
|
|
328
339
|
const merged = new Map(
|
|
329
|
-
(track
|
|
330
|
-
point.tick,
|
|
331
|
-
point,
|
|
332
|
-
]),
|
|
340
|
+
automationPoints(track, parameter).map((point) => [point.tick, point]),
|
|
333
341
|
);
|
|
334
342
|
merged.set(tick, { tick, value: command.value });
|
|
335
343
|
points = [...merged.values()].sort((a, b) => a.tick - b.tick);
|
|
336
344
|
if (points.length > SCORE_LIMITS.maxAutomationPoints)
|
|
337
|
-
return { message: `${parameter} automation is full` };
|
|
345
|
+
return { ok: false, message: `${parameter} automation is full` };
|
|
338
346
|
}
|
|
339
347
|
return {
|
|
348
|
+
ok: true,
|
|
340
349
|
message: `automation · ${parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
|
|
341
350
|
next: setTrackAutomation(score, trackId, parameter, points),
|
|
342
351
|
kind: "score.automation",
|
|
@@ -345,7 +354,10 @@ export function applyMusicCommand(
|
|
|
345
354
|
}
|
|
346
355
|
|
|
347
356
|
if (!isDrumInstrument(track.instrument))
|
|
348
|
-
return {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
message: `${trackId} is not a drum track · try instrument kit`,
|
|
360
|
+
};
|
|
349
361
|
|
|
350
362
|
if (command.type === "drum-clear") {
|
|
351
363
|
const removed = score.notes.filter(
|
|
@@ -353,12 +365,14 @@ export function applyMusicCommand(
|
|
|
353
365
|
note.trackId === trackId &&
|
|
354
366
|
drumVoiceForPitch(note.pitch) === command.voice,
|
|
355
367
|
);
|
|
356
|
-
if (removed.length === 0)
|
|
368
|
+
if (removed.length === 0)
|
|
369
|
+
return { ok: false, message: `no ${command.voice} hits` };
|
|
357
370
|
const next = createScore({
|
|
358
371
|
...score.toJSON(),
|
|
359
372
|
notes: score.notes.filter((note) => !removed.includes(note)),
|
|
360
373
|
});
|
|
361
374
|
return {
|
|
375
|
+
ok: true,
|
|
362
376
|
message: `cleared ${removed.length} ${command.voice} hit${removed.length === 1 ? "" : "s"}`,
|
|
363
377
|
next,
|
|
364
378
|
kind: "score.drums",
|
|
@@ -371,7 +385,7 @@ export function applyMusicCommand(
|
|
|
371
385
|
? command.beats
|
|
372
386
|
: everyBeats(command.from, command.step, score.bars * score.beatsPerBar);
|
|
373
387
|
if (beats.length > MAX_PATTERN_HITS)
|
|
374
|
-
return { message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
|
|
388
|
+
return { ok: false, message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
|
|
375
389
|
const pitch = drumVoicePitch(command.voice);
|
|
376
390
|
const existing = new Set(
|
|
377
391
|
score.notes
|
|
@@ -393,14 +407,19 @@ export function applyMusicCommand(
|
|
|
393
407
|
velocity: command.velocity,
|
|
394
408
|
});
|
|
395
409
|
}
|
|
396
|
-
if (hits.length === 0)
|
|
410
|
+
if (hits.length === 0)
|
|
411
|
+
return { ok: false, message: `${command.voice} already there` };
|
|
397
412
|
if (score.notes.length + hits.length > SCORE_LIMITS.maxNotes)
|
|
398
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
message: `score is full (${SCORE_LIMITS.maxNotes} notes)`,
|
|
416
|
+
};
|
|
399
417
|
const next = createScore({
|
|
400
418
|
...score.toJSON(),
|
|
401
419
|
notes: [...score.notes, ...hits],
|
|
402
420
|
});
|
|
403
421
|
return {
|
|
422
|
+
ok: true,
|
|
404
423
|
message: `+${hits.length} ${command.voice} hit${hits.length === 1 ? "" : "s"}`,
|
|
405
424
|
next,
|
|
406
425
|
kind: "score.drums",
|
|
@@ -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
|
+
}
|