@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -26,7 +26,9 @@ import {
26
26
  createScore,
27
27
  updateTrack,
28
28
  setTrackAutomation,
29
- AUTOMATION_LANES,
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
- return Object.prototype.hasOwnProperty.call(LANE_ALIASES, name)
100
- ? LANE_ALIASES[name]
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 } = AUTOMATION_LANES[parameter];
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[AUTOMATION_LANES[parameter].field] ?? []).map((point) => [
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 { message: `${trackId} is not a drum track · try instrument kit` };
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) return { message: `no ${command.voice} hits` };
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) return { message: `${command.voice} already there` };
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 { message: `score is full (${SCORE_LIMITS.maxNotes} notes)` };
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
+ }