@hraness/dawg 0.3.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 (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -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 +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -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 +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -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 +280 -278
  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 +1119 -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 +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -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 +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,1787 @@
1
+ /**
2
+ * Sample packs: Strudel-style sample manifests used from dawg.
3
+ *
4
+ * A pack is a JSON manifest in the format Strudel's `samples(...)` reads
5
+ * (`{"_base": url, sound: [files] | file | {note: file | [files]}}`) or the
6
+ * `github:user/repo[/branch]` shorthand for `<repo>/<branch>/strudel.json`
7
+ * (branch defaults to `main`). This is a clean-room loader for that public
8
+ * format; no Strudel code is used.
9
+ *
10
+ * Manifests are fetched on first use and cached under
11
+ * `$XDG_CACHE_HOME/dawg/packs` (default `~/.cache/dawg/packs`). Sample files
12
+ * are fetched one at a time, only when a track first uses them, and kept in
13
+ * `files/` by URL; decoded PCM then lands in the project's sha256 asset cache
14
+ * like any other sample. With a cached manifest and files nothing touches the
15
+ * network. Every pinned sound records its pack and license.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import {
19
+ cacheUsage,
20
+ packsCacheMax,
21
+ pruneLru,
22
+ type CacheUsage,
23
+ type PruneResult,
24
+ } from "./cache.ts";
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+ import {
28
+ mkdir,
29
+ readFile,
30
+ readdir,
31
+ rename,
32
+ stat,
33
+ unlink,
34
+ utimes,
35
+ writeFile,
36
+ } from "node:fs/promises";
37
+ import {
38
+ formatPackRef,
39
+ isPinnedSampleUrl,
40
+ parsePackRef,
41
+ SCORE_LIMITS,
42
+ type PackRef,
43
+ type Sampler,
44
+ type SampleRef,
45
+ } from "../../core/score.ts";
46
+ import {
47
+ DRUM_VOICES,
48
+ parseDrumVoice,
49
+ type DrumVoice,
50
+ } from "../../core/drums.ts";
51
+
52
+ export const PACK_LIMITS = Object.freeze({
53
+ maxManifestBytes: 8 * 1024 * 1024,
54
+ maxSounds: 20_000,
55
+ maxFilesPerSound: 1_024,
56
+ maxFileNameLength: 512,
57
+ fetchTimeoutMs: 30_000,
58
+ maxRedirects: 4,
59
+ /** Zones a keyed instrument keeps (the sampler voice limit). */
60
+ maxZones: SCORE_LIMITS.maxSamplerVoices,
61
+ });
62
+
63
+ /** License label for packs whose repository states none. */
64
+ export const NO_LICENSE = "none stated";
65
+
66
+ export type PackKind = "strudel" | "gm";
67
+
68
+ export type PackInfo = Readonly<{
69
+ /** Slug used in `pack:<name>/...`. */
70
+ name: string;
71
+ title: string;
72
+ /** What the user typed: URL or `github:` shorthand. */
73
+ source: string;
74
+ /** Resolved HTTPS manifest URL. */
75
+ manifestUrl: string;
76
+ /** SPDX id, or `none stated`. */
77
+ license: string;
78
+ /** Attribution line for CC-BY / CC-BY-SA packs. */
79
+ attribution?: string;
80
+ homepage?: string;
81
+ kind: PackKind;
82
+ builtin: boolean;
83
+ /**
84
+ * Bank nicknames (`{"RolandTR909": "TR909"}`, Strudel's `aliasBank` file),
85
+ * fetched with the manifest when the pack has them.
86
+ */
87
+ aliasesUrl?: string;
88
+ }>;
89
+
90
+ /** `github:user/repo[/branch]` (Strudel's shorthand). */
91
+ const GITHUB =
92
+ /^github:([A-Za-z0-9_.-]{1,100})\/([A-Za-z0-9_.-]{1,100})(?:\/([A-Za-z0-9_./-]{1,200}))?\/?$/;
93
+
94
+ const DOUGH = "https://raw.githubusercontent.com/felixroos/dough-samples/main";
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Bank nicknames
98
+
99
+ /**
100
+ * The bank alias file Strudel's REPL registers at startup with
101
+ * `aliasBank(".../tidal-drum-machines-alias.json")`: full bank name →
102
+ * nickname. dawg ships the snapshot below (fetched 2026-10-07, sha256
103
+ * 2de712d3…797e) so nicknames work offline, and refreshes it from this URL
104
+ * whenever it fetches the tidal-drum-machines manifest.
105
+ */
106
+ export const BANK_ALIASES_URL =
107
+ "https://strudel.b-cdn.net/tidal-drum-machines-alias.json";
108
+
109
+ export const STRUDEL_BANK_ALIASES: Readonly<Record<string, string>> =
110
+ Object.freeze({
111
+ AJKPercusyn: "Percysyn",
112
+ AkaiLinn: "Linn",
113
+ AkaiMPC60: "MPC60",
114
+ AkaiXR10: "XR10",
115
+ AlesisHR16: "HR16",
116
+ AlesisSR16: "SR16",
117
+ BossDR110: "DR110",
118
+ BossDR220: "DR220",
119
+ BossDR55: "DR55",
120
+ BossDR550: "DR550",
121
+ CasioRZ1: "RZ1",
122
+ CasioSK1: "SK1",
123
+ CasioVL1: "VL1",
124
+ DoepferMS404: "MS404",
125
+ EmuDrumulator: "Drumulator",
126
+ EmuSP12: "SP12",
127
+ KorgDDM110: "DDM110",
128
+ KorgKPR77: "KPR77",
129
+ KorgKR55: "KR55",
130
+ KorgKRZ: "KRZ",
131
+ KorgM1: "M1",
132
+ KorgMinipops: "Minipops",
133
+ KorgPoly800: "Poly800",
134
+ KorgT3: "T3",
135
+ Linn9000: "9000",
136
+ LinnLM1: "LM1",
137
+ LinnLM2: "LM2",
138
+ MoogConcertMateMG1: "ConcertMateMG1",
139
+ OberheimDMX: "DMX",
140
+ RhodesPolaris: "Polaris",
141
+ RhythmAce: "Ace",
142
+ RolandCompurhythm1000: "Compurhythm1000",
143
+ RolandCompurhythm78: "Compurhythm78",
144
+ RolandCompurhythm8000: "Compurhythm8000",
145
+ RolandD110: "D110",
146
+ RolandD70: "D70",
147
+ RolandDDR30: "DDR30",
148
+ RolandJD990: "JD990",
149
+ RolandMC202: "MC202",
150
+ RolandMC303: "MC303",
151
+ RolandMT32: "MT32",
152
+ RolandR8: "R8",
153
+ RolandS50: "S50",
154
+ RolandSH09: "SH09",
155
+ RolandSystem100: "System100",
156
+ RolandTR505: "TR505",
157
+ RolandTR606: "TR606",
158
+ RolandTR626: "TR626",
159
+ RolandTR707: "TR707",
160
+ RolandTR727: "TR727",
161
+ RolandTR808: "TR808",
162
+ RolandTR909: "TR909",
163
+ SakataDPM48: "DPM48",
164
+ SequentialCircuitsDrumtracks: "CircuitsDrumtracks",
165
+ SequentialCircuitsTom: "CircuitsTom",
166
+ SimmonsSDS400: "SDS400",
167
+ SimmonsSDS5: "SDS5",
168
+ SoundmastersR88: "R88",
169
+ UnivoxMicroRhythmer12: "MicroRhythmer12",
170
+ ViscoSpaceDrum: "SpaceDrum",
171
+ XdrumLM8953: "LM8953",
172
+ YamahaRM50: "RM50",
173
+ YamahaRX21: "RX21",
174
+ YamahaRX5: "RX5",
175
+ YamahaRY30: "RY30",
176
+ YamahaTG33: "TG33",
177
+ });
178
+
179
+ /** Lowercased nickname or bank → bank, plus exact-case nicknames. */
180
+ export type BankAliases = Readonly<{
181
+ exact: ReadonlyMap<string, string>;
182
+ folded: ReadonlyMap<string, string>;
183
+ /** bank → nickname, for listings. */
184
+ nicknames: ReadonlyMap<string, string>;
185
+ }>;
186
+
187
+ /** Builds the lookup for a `{bank: nickname}` map; ignores malformed entries. */
188
+ export function bankAliasIndex(
189
+ ...maps: readonly Readonly<Record<string, unknown>>[]
190
+ ): BankAliases {
191
+ const exact = new Map<string, string>();
192
+ const folded = new Map<string, string>();
193
+ const nicknames = new Map<string, string>();
194
+ for (const map of maps)
195
+ for (const [bank, alias] of Object.entries(map).slice(0, 4096)) {
196
+ if (typeof alias !== "string" || !SOUND_NAME.test(bank)) continue;
197
+ if (!SOUND_NAME.test(alias)) continue;
198
+ exact.set(alias, bank);
199
+ folded.set(alias.toLowerCase(), bank);
200
+ nicknames.set(bank, alias);
201
+ }
202
+ return Object.freeze({ exact, folded, nicknames });
203
+ }
204
+
205
+ let builtinAliases: BankAliases | undefined;
206
+ /** The shipped snapshot's lookup. */
207
+ export function builtinBankAliases(): BankAliases {
208
+ builtinAliases ??= bankAliasIndex(STRUDEL_BANK_ALIASES);
209
+ return builtinAliases;
210
+ }
211
+
212
+ /**
213
+ * The bank a nickname names, Strudel-style: an exact nickname (`TR909`,
214
+ * `Linn`) first, then the same case-insensitively (`tr909`, `sp12`).
215
+ * Undefined when `name` is no nickname.
216
+ */
217
+ export function resolveBankAlias(
218
+ name: string,
219
+ aliases: BankAliases = builtinBankAliases(),
220
+ ): string | undefined {
221
+ return aliases.exact.get(name) ?? aliases.folded.get(name.toLowerCase());
222
+ }
223
+
224
+ /**
225
+ * Packs dawg knows without `/pack add`. Manifest URLs are the ones Strudel's
226
+ * default sample set loads (dough-samples hosts the drum-machine, VCSL,
227
+ * piano and mridangam manifests; Dirt-Samples and uzu-drumkit ship their own
228
+ * strudel.json). Licenses were read from each repository.
229
+ */
230
+ export const PACK_CATALOG: readonly PackInfo[] = Object.freeze(
231
+ (
232
+ [
233
+ {
234
+ name: "tidal-drum-machines",
235
+ title: "Tidal drum machines (TR-808, TR-909, LinnDrum, …)",
236
+ source: `${DOUGH}/tidal-drum-machines.json`,
237
+ license: NO_LICENSE,
238
+ homepage: "https://github.com/ritchse/tidal-drum-machines",
239
+ kind: "strudel",
240
+ aliasesUrl: BANK_ALIASES_URL,
241
+ },
242
+ {
243
+ name: "dirt-samples",
244
+ title: "TidalCycles Dirt-Samples",
245
+ source: "github:tidalcycles/dirt-samples",
246
+ license: NO_LICENSE,
247
+ homepage: "https://github.com/tidalcycles/Dirt-Samples",
248
+ kind: "strudel",
249
+ },
250
+ {
251
+ name: "uzu-drumkit",
252
+ title: "uzu drumkit",
253
+ source: "github:tidalcycles/uzu-drumkit",
254
+ license: "Unlicense",
255
+ homepage: "https://github.com/tidalcycles/uzu-drumkit",
256
+ kind: "strudel",
257
+ },
258
+
259
+ {
260
+ name: "uzu-wavetables",
261
+ title: "uzu wavetables (Strudel's wt_ tables)",
262
+ source: "github:tidalcycles/uzu-wavetables",
263
+ license: "Unlicense",
264
+ homepage: "https://github.com/tidalcycles/uzu-wavetables",
265
+ kind: "strudel",
266
+ },
267
+ {
268
+ name: "vcsl",
269
+ title: "Versilian Community Sample Library",
270
+ source: `${DOUGH}/vcsl.json`,
271
+ license: "CC0-1.0",
272
+ homepage: "https://github.com/sgossner/VCSL",
273
+ kind: "strudel",
274
+ },
275
+ {
276
+ name: "piano",
277
+ title: "Salamander Grand Piano",
278
+ source: `${DOUGH}/piano.json`,
279
+ license: "CC-BY-3.0",
280
+ attribution:
281
+ "Salamander Grand Piano V3 by Alexander Holm (CC BY 3.0, https://creativecommons.org/licenses/by/3.0/)",
282
+ homepage: "https://archive.org/details/SalamanderGrandPianoV3",
283
+ kind: "strudel",
284
+ },
285
+ {
286
+ name: "mridangam",
287
+ title: "Mridangam",
288
+ source: `${DOUGH}/mridangam.json`,
289
+ license: "CC-BY-SA-4.0",
290
+ attribution:
291
+ "Mridangam samples (c) Arthur Carabott 2022, performer Harishankar V Menon (CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/)",
292
+ homepage: "https://github.com/felixroos/dough-samples",
293
+ kind: "strudel",
294
+ },
295
+ {
296
+ name: "emu-sp12",
297
+ title: "E-mu SP-12",
298
+ source: `${DOUGH}/EmuSP12.json`,
299
+ license: NO_LICENSE,
300
+ homepage: "https://github.com/felixroos/dough-samples",
301
+ kind: "strudel",
302
+ },
303
+ {
304
+ name: "gm",
305
+ title: "General MIDI soundfont (FluidR3_GM)",
306
+ source:
307
+ "https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/names.json",
308
+ license: "CC-BY-3.0",
309
+ attribution:
310
+ "FluidR3_GM soundfont by Frank Wen, via gleitz/midi-js-soundfonts (CC BY 3.0, https://creativecommons.org/licenses/by/3.0/)",
311
+ homepage: "https://github.com/gleitz/midi-js-soundfonts",
312
+ kind: "gm",
313
+ },
314
+ {
315
+ name: "gm-musyngkite",
316
+ title: "General MIDI soundfont (Musyng Kite)",
317
+ source:
318
+ "https://gleitz.github.io/midi-js-soundfonts/MusyngKite/names.json",
319
+ license: "CC-BY-SA-3.0",
320
+ attribution:
321
+ "Musyng Kite soundfont, via gleitz/midi-js-soundfonts (CC BY-SA 3.0, https://creativecommons.org/licenses/by-sa/3.0/)",
322
+ homepage: "https://github.com/gleitz/midi-js-soundfonts",
323
+ kind: "gm",
324
+ },
325
+ ] as const
326
+ ).map((entry) =>
327
+ Object.freeze({
328
+ ...entry,
329
+ manifestUrl: resolveManifestUrl(entry.source),
330
+ builtin: true,
331
+ }),
332
+ ),
333
+ );
334
+
335
+ export class PackError extends Error {
336
+ constructor(message: string) {
337
+ super(message);
338
+ this.name = "PackError";
339
+ }
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // URLs
344
+
345
+ /**
346
+ * Manifest URL for a pack source. `github:user/repo[/branch]` reads
347
+ * `strudel.json` at the root of that branch (default `main`); a URL ending in
348
+ * `/` gets `strudel.json` appended; anything else is used as is.
349
+ */
350
+ export function resolveManifestUrl(source: string): string {
351
+ const trimmed = source.trim();
352
+ const github = GITHUB.exec(trimmed);
353
+ if (github) {
354
+ const [, user, repo, branch] = github;
355
+ if (
356
+ [user, repo, branch].some(
357
+ (part) => part && /(^|\/)\.\.?(\/|$)/.test(part),
358
+ )
359
+ )
360
+ throw new PackError(`${trimmed} · not a valid github: shorthand`);
361
+ return `https://raw.githubusercontent.com/${user}/${repo}/${(branch ?? "main").replace(/\/$/, "")}/strudel.json`;
362
+ }
363
+ if (trimmed.startsWith("github:"))
364
+ throw new PackError(
365
+ `${trimmed.slice(0, 80)} · use github:<user>/<repo>[/<branch>]`,
366
+ );
367
+ return trimmed.endsWith("/") ? `${trimmed}strudel.json` : trimmed;
368
+ }
369
+
370
+ function isLoopbackHost(hostname: string): boolean {
371
+ return (
372
+ hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]"
373
+ );
374
+ }
375
+
376
+ /**
377
+ * The URL rule for anything dawg downloads: HTTPS, no credentials, bounded;
378
+ * plain HTTP only to loopback when the store allows it (tests).
379
+ */
380
+ export function checkFetchUrl(value: string, allowLoopbackHttp = false): URL {
381
+ let url: URL;
382
+ try {
383
+ url = new URL(value);
384
+ } catch {
385
+ throw new PackError(`${value.slice(0, 80)} · not a URL`);
386
+ }
387
+ if (url.username || url.password)
388
+ throw new PackError(
389
+ `${url.hostname} · credentials in URLs are not allowed · use a public URL`,
390
+ );
391
+ const loopbackHttp =
392
+ url.protocol === "http:" &&
393
+ allowLoopbackHttp &&
394
+ isLoopbackHost(url.hostname);
395
+ if (url.protocol !== "https:" && !loopbackHttp)
396
+ throw new PackError(`${value.slice(0, 80)} · packs must use https://`);
397
+ if (value.length > SCORE_LIMITS.maxSampleUrlLength)
398
+ throw new PackError(
399
+ `URL is over ${SCORE_LIMITS.maxSampleUrlLength} characters`,
400
+ );
401
+ return url;
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Manifests
406
+
407
+ export type ManifestZone = Readonly<{
408
+ note: number;
409
+ name: string;
410
+ file: string;
411
+ }>;
412
+
413
+ export type ManifestSound =
414
+ | Readonly<{ kind: "list"; files: readonly string[] }>
415
+ | Readonly<{ kind: "zones"; zones: readonly ManifestZone[] }>;
416
+
417
+ export type Manifest = Readonly<{
418
+ /** Absolute base URL files resolve against. */
419
+ base: string;
420
+ sounds: ReadonlyMap<string, ManifestSound>;
421
+ }>;
422
+
423
+ const SOUND_NAME = /^[A-Za-z0-9][A-Za-z0-9_.~+-]{0,127}$/;
424
+ const NOTE_NAME = /^([A-Ga-g])(#|s|b)?(-?[0-9])$/;
425
+ const PITCH_CLASS: Readonly<Record<string, number>> = {
426
+ c: 0,
427
+ d: 2,
428
+ e: 4,
429
+ f: 5,
430
+ g: 7,
431
+ a: 9,
432
+ b: 11,
433
+ };
434
+
435
+ /** MIDI number for a manifest note key (`A0`, `c4`, `Ds1`, `Bb3`). */
436
+ export function noteNumber(name: string): number | undefined {
437
+ const match = NOTE_NAME.exec(name);
438
+ if (!match) return undefined;
439
+ const accidental = match[2] === "b" ? -1 : match[2] ? 1 : 0;
440
+ const midi =
441
+ (Number(match[3]) + 1) * 12 +
442
+ PITCH_CLASS[match[1]!.toLowerCase()]! +
443
+ accidental;
444
+ return midi >= 0 && midi <= 127 ? midi : undefined;
445
+ }
446
+
447
+ function checkFileName(value: unknown, where: string): string {
448
+ if (
449
+ typeof value !== "string" ||
450
+ value.length === 0 ||
451
+ value.length > PACK_LIMITS.maxFileNameLength ||
452
+ /[\u0000-\u001f]/.test(value)
453
+ )
454
+ throw new PackError(`${where} · file names must be short strings`);
455
+ return value;
456
+ }
457
+
458
+ /** Parses a Strudel-format manifest; `manifestUrl` is the default base. */
459
+ export function parseManifest(json: unknown, manifestUrl: string): Manifest {
460
+ if (typeof json !== "object" || json === null || Array.isArray(json))
461
+ throw new PackError("manifest must be a JSON object of sounds");
462
+ const record = json as Record<string, unknown>;
463
+ let base = new URL(".", manifestUrl).href;
464
+ if (record._base !== undefined) {
465
+ if (typeof record._base !== "string")
466
+ throw new PackError("manifest _base must be a URL string");
467
+ base = new URL(record._base, manifestUrl).href;
468
+ }
469
+ if (!base.endsWith("/")) base += "/";
470
+ const sounds = new Map<string, ManifestSound>();
471
+ for (const [key, value] of Object.entries(record)) {
472
+ if (key.startsWith("_")) continue;
473
+ if (!SOUND_NAME.test(key)) continue;
474
+ if (sounds.size >= PACK_LIMITS.maxSounds)
475
+ throw new PackError(
476
+ `manifest has more than ${PACK_LIMITS.maxSounds} sounds`,
477
+ );
478
+ if (typeof value === "string")
479
+ sounds.set(key, { kind: "list", files: [checkFileName(value, key)] });
480
+ else if (Array.isArray(value)) {
481
+ if (value.length === 0) continue;
482
+ if (value.length > PACK_LIMITS.maxFilesPerSound)
483
+ throw new PackError(
484
+ `${key} has more than ${PACK_LIMITS.maxFilesPerSound} files`,
485
+ );
486
+ sounds.set(key, {
487
+ kind: "list",
488
+ files: Object.freeze(value.map((file) => checkFileName(file, key))),
489
+ });
490
+ } else if (typeof value === "object" && value !== null) {
491
+ const zones: ManifestZone[] = [];
492
+ for (const [name, file] of Object.entries(
493
+ value as Record<string, unknown>,
494
+ )) {
495
+ const note = noteNumber(name);
496
+ if (note === undefined) continue;
497
+ const first = Array.isArray(file) ? file[0] : file;
498
+ zones.push({
499
+ note,
500
+ name,
501
+ file: checkFileName(first, `${key}.${name}`),
502
+ });
503
+ }
504
+ if (zones.length === 0) continue;
505
+ if (zones.length > PACK_LIMITS.maxFilesPerSound)
506
+ throw new PackError(
507
+ `${key} has more than ${PACK_LIMITS.maxFilesPerSound} zones`,
508
+ );
509
+ zones.sort((a, b) => a.note - b.note);
510
+ sounds.set(key, { kind: "zones", zones: Object.freeze(zones) });
511
+ }
512
+ }
513
+ return Object.freeze({ base, sounds });
514
+ }
515
+
516
+ /** Notes every soundfont instrument in midi-js-soundfonts has (A0..C8). */
517
+ const GM_FILE_NOTES = (() => {
518
+ const names = [
519
+ "C",
520
+ "Db",
521
+ "D",
522
+ "Eb",
523
+ "E",
524
+ "F",
525
+ "Gb",
526
+ "G",
527
+ "Ab",
528
+ "A",
529
+ "Bb",
530
+ "B",
531
+ ];
532
+ const out: { note: number; name: string }[] = [];
533
+ for (let midi = 21; midi <= 108; midi += 1)
534
+ out.push({
535
+ note: midi,
536
+ name: `${names[midi % 12]}${Math.floor(midi / 12) - 1}`,
537
+ });
538
+ return out;
539
+ })();
540
+
541
+ /**
542
+ * A soundfont pack from midi-js-soundfonts' `names.json` (a list of GM
543
+ * instrument names): each becomes `gm_<name>`, a keyed sound with one MP3 per
544
+ * note from A0 to C8.
545
+ */
546
+ export function parseGmNames(json: unknown, manifestUrl: string): Manifest {
547
+ if (!Array.isArray(json) || json.length > 512)
548
+ throw new PackError("soundfont names.json must be a list of names");
549
+ const base = new URL(".", manifestUrl).href;
550
+ const sounds = new Map<string, ManifestSound>();
551
+ for (const name of json) {
552
+ if (typeof name !== "string" || !/^[a-z0-9_]{1,64}$/.test(name)) continue;
553
+ sounds.set(`gm_${name}`, {
554
+ kind: "zones",
555
+ zones: Object.freeze(
556
+ GM_FILE_NOTES.map(({ note, name: noteName }) => ({
557
+ note,
558
+ name: noteName,
559
+ file: `${name}-mp3/${noteName}.mp3`,
560
+ })),
561
+ ),
562
+ });
563
+ }
564
+ return Object.freeze({ base, sounds });
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Fetching
569
+
570
+ export type FetchLike = (
571
+ input: string,
572
+ init?: RequestInit,
573
+ ) => Promise<Response>;
574
+
575
+ /**
576
+ * GET with the pack URL rule applied to the URL and every redirect, a
577
+ * timeout, and a byte limit enforced while streaming.
578
+ */
579
+ export async function fetchBounded(
580
+ url: string,
581
+ options: Readonly<{
582
+ maxBytes: number;
583
+ fetch?: FetchLike;
584
+ timeoutMs?: number;
585
+ allowLoopbackHttp?: boolean;
586
+ signal?: AbortSignal;
587
+ }>,
588
+ ): Promise<Uint8Array> {
589
+ const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
590
+ const timeout = AbortSignal.timeout(
591
+ options.timeoutMs ?? PACK_LIMITS.fetchTimeoutMs,
592
+ );
593
+ const signal = options.signal
594
+ ? AbortSignal.any([timeout, options.signal])
595
+ : timeout;
596
+ let current = checkFetchUrl(url, options.allowLoopbackHttp).href;
597
+ for (let hop = 0; ; hop += 1) {
598
+ let response: Response;
599
+ try {
600
+ response = await doFetch(current, {
601
+ redirect: "manual",
602
+ signal,
603
+ headers: { accept: "*/*" },
604
+ });
605
+ } catch (error) {
606
+ if (timeout.aborted)
607
+ throw new PackError(`${shortUrl(current)} · timed out`);
608
+ throw new PackError(
609
+ `${shortUrl(current)} · ${error instanceof Error ? error.message.slice(0, 120) : "fetch failed"} · offline? cached sounds still play`,
610
+ );
611
+ }
612
+ if (response.status >= 300 && response.status < 400) {
613
+ const location = response.headers.get("location");
614
+ await response.body?.cancel().catch(() => undefined);
615
+ if (!location || hop >= PACK_LIMITS.maxRedirects)
616
+ throw new PackError(`${shortUrl(current)} · too many redirects`);
617
+ current = checkFetchUrl(
618
+ new URL(location, current).href,
619
+ options.allowLoopbackHttp,
620
+ ).href;
621
+ continue;
622
+ }
623
+ if (!response.ok) {
624
+ await response.body?.cancel().catch(() => undefined);
625
+ throw new PackError(`${shortUrl(current)} · HTTP ${response.status}`);
626
+ }
627
+ const declared = Number(response.headers.get("content-length") ?? "0");
628
+ if (declared > options.maxBytes) {
629
+ await response.body?.cancel().catch(() => undefined);
630
+ throw new PackError(
631
+ `${shortUrl(current)} · ${formatMiB(declared)} is over the ${formatMiB(options.maxBytes)} limit`,
632
+ );
633
+ }
634
+ const chunks: Uint8Array[] = [];
635
+ let total = 0;
636
+ const reader = response.body?.getReader();
637
+ if (reader)
638
+ for (;;) {
639
+ let next: Awaited<ReturnType<typeof reader.read>>;
640
+ try {
641
+ next = await reader.read();
642
+ } catch {
643
+ throw new PackError(
644
+ `${shortUrl(current)} · ${timeout.aborted ? "timed out" : "download interrupted"}`,
645
+ );
646
+ }
647
+ if (next.done) break;
648
+ total += next.value.byteLength;
649
+ if (total > options.maxBytes) {
650
+ await reader.cancel().catch(() => undefined);
651
+ throw new PackError(
652
+ `${shortUrl(current)} · over the ${formatMiB(options.maxBytes)} limit`,
653
+ );
654
+ }
655
+ chunks.push(next.value);
656
+ }
657
+ const out = new Uint8Array(total);
658
+ let offset = 0;
659
+ for (const chunk of chunks) {
660
+ out.set(chunk, offset);
661
+ offset += chunk.byteLength;
662
+ }
663
+ return out;
664
+ }
665
+ }
666
+
667
+ function shortUrl(url: string): string {
668
+ return url.length > 96 ? `${url.slice(0, 93)}…` : url;
669
+ }
670
+
671
+ function formatMiB(bytes: number): string {
672
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MiB`;
673
+ }
674
+
675
+ function sha256Hex(data: Uint8Array | string): string {
676
+ return createHash("sha256").update(data).digest("hex");
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // Store
681
+
682
+ export function defaultPackDir(env: NodeJS.ProcessEnv = process.env): string {
683
+ if (env.DAWG_PACKS_DIR) return env.DAWG_PACKS_DIR;
684
+ const cache = env.XDG_CACHE_HOME || join(env.HOME || homedir(), ".cache");
685
+ return join(cache, "dawg", "packs");
686
+ }
687
+
688
+ export type PackStoreOptions = Readonly<{
689
+ /** Default `defaultPackDir()`. */
690
+ dir?: string;
691
+ fetch?: FetchLike;
692
+ /** Allow `http://127.0.0.1` sources (tests); default `DAWG_PACKS_ALLOW_LOOPBACK_HTTP=1`. */
693
+ allowLoopbackHttp?: boolean;
694
+ /** Never touch the network; only cached manifests and files. */
695
+ offline?: boolean;
696
+ timeoutMs?: number;
697
+ /**
698
+ * Cap on raw downloaded files; default `packsCacheMax()`
699
+ * (`DAWG_PACKS_CACHE_MAX`, 2 GiB). Decoded PCM lives in the project's
700
+ * asset cache.
701
+ */
702
+ maxFileCacheBytes?: number;
703
+ }>;
704
+
705
+ /** A pack sound resolved to one concrete file. */
706
+ export type ResolvedSound = Readonly<{
707
+ pack: string;
708
+ sound: string;
709
+ ref: PackRef;
710
+ src: string;
711
+ url: string;
712
+ license: string;
713
+ /** Keyed zones: the note the file plays at. */
714
+ root?: number;
715
+ }>;
716
+
717
+ export type FetchedFile = Readonly<{
718
+ url: string;
719
+ sha256: string;
720
+ bytes: Uint8Array;
721
+ /** On-disk copy (ffmpeg decodes from it). */
722
+ path: string;
723
+ cached: boolean;
724
+ }>;
725
+
726
+ type IndexFile = {
727
+ version: 1;
728
+ packs: Record<
729
+ string,
730
+ Omit<PackInfo, "builtin" | "manifestUrl"> & { addedAt: string }
731
+ >;
732
+ };
733
+
734
+ const PACK_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
735
+
736
+ /** Slug for a pack added from `source` (`github:a/b` → `b`). */
737
+ export function packNameFromSource(source: string): string {
738
+ const github = GITHUB.exec(source.trim());
739
+ let raw: string;
740
+ if (github) raw = github[2]!;
741
+ else {
742
+ let path = source;
743
+ try {
744
+ path = new URL(source).pathname;
745
+ } catch {
746
+ /* fall through */
747
+ }
748
+ const parts = path.split("/").filter(Boolean);
749
+ let last = parts.pop() ?? "pack";
750
+ if (/^strudel\.json$/i.test(last)) last = parts.pop() ?? "pack";
751
+ raw = last.replace(/\.json$/i, "");
752
+ }
753
+ const slug = raw
754
+ .toLowerCase()
755
+ .replace(/[^a-z0-9._-]+/g, "-")
756
+ .replace(/^[^a-z0-9]+/, "")
757
+ .slice(0, 64);
758
+ return slug || "pack";
759
+ }
760
+
761
+ /**
762
+ * The pack registry, manifest cache and raw file cache. Safe to share; all
763
+ * state lives on disk and in small in-memory memos.
764
+ */
765
+ export class PackStore {
766
+ public readonly dir: string;
767
+ private readonly fetchImpl: FetchLike | undefined;
768
+ private readonly allowLoopbackHttp: boolean;
769
+ private readonly offline: boolean;
770
+ private readonly timeoutMs: number;
771
+ private readonly manifests = new Map<string, Promise<Manifest>>();
772
+ private readonly inflight = new Map<string, Promise<FetchedFile>>();
773
+ private readonly aliases = new Map<string, BankAliases>();
774
+ public readonly maxFileCacheBytes: number;
775
+ /**
776
+ * Cache file names (`<sha256(url)>.bin`) the open project pins, shared by
777
+ * every store on the same directory in this process.
778
+ */
779
+ private get protectedFiles(): Set<string> {
780
+ let set = PROTECTED.get(this.dir);
781
+ if (!set) PROTECTED.set(this.dir, (set = new Set()));
782
+ return set;
783
+ }
784
+
785
+ public constructor(options: PackStoreOptions = {}) {
786
+ this.dir = options.dir ?? defaultPackDir();
787
+ this.fetchImpl = options.fetch;
788
+ this.allowLoopbackHttp =
789
+ options.allowLoopbackHttp ??
790
+ process.env.DAWG_PACKS_ALLOW_LOOPBACK_HTTP === "1";
791
+ this.offline = options.offline ?? false;
792
+ this.timeoutMs = options.timeoutMs ?? PACK_LIMITS.fetchTimeoutMs;
793
+ this.maxFileCacheBytes = Math.max(
794
+ 0,
795
+ options.maxFileCacheBytes ?? packsCacheMax(),
796
+ );
797
+ }
798
+
799
+ // -- file cache -----------------------------------------------------------
800
+
801
+ private filePath(url: string): string {
802
+ return join(this.dir, "files", `${sha256Hex(url)}.bin`);
803
+ }
804
+
805
+ /**
806
+ * Marks pinned URLs as the open project's working set: LRU pruning never
807
+ * evicts them. Replaces the previous set.
808
+ */
809
+ public protect(urls: Iterable<string>): void {
810
+ this.protectedFiles.clear();
811
+ for (const url of urls) this.protectedFiles.add(`${sha256Hex(url)}.bin`);
812
+ }
813
+
814
+ /** Raw file cache usage and its cap. */
815
+ public async cacheStatus(): Promise<CacheUsage & { max: number }> {
816
+ return {
817
+ ...(await cacheUsage(join(this.dir, "files"), PACK_FILE)),
818
+ max: this.maxFileCacheBytes,
819
+ };
820
+ }
821
+
822
+ /**
823
+ * Prunes raw files down to `maxBytes` (default the cap), least recently
824
+ * used first, keeping protected files. `0` clears everything unprotected.
825
+ */
826
+ public pruneCache(
827
+ maxBytes = this.maxFileCacheBytes,
828
+ keep?: string,
829
+ ): Promise<PruneResult> {
830
+ const keepName = keep ? keep.slice(keep.lastIndexOf("/") + 1) : undefined;
831
+ return pruneLru(
832
+ join(this.dir, "files"),
833
+ PACK_FILE,
834
+ maxBytes,
835
+ (name) => name === keepName || this.protectedFiles.has(name),
836
+ );
837
+ }
838
+
839
+ // -- registry -------------------------------------------------------------
840
+
841
+ private async readIndex(): Promise<IndexFile> {
842
+ try {
843
+ const parsed = JSON.parse(
844
+ await readFile(join(this.dir, "index.json"), "utf8"),
845
+ ) as IndexFile;
846
+ if (
847
+ parsed?.version === 1 &&
848
+ parsed.packs &&
849
+ typeof parsed.packs === "object"
850
+ )
851
+ return parsed;
852
+ } catch {
853
+ /* missing or unreadable: empty registry */
854
+ }
855
+ return { version: 1, packs: {} };
856
+ }
857
+
858
+ private async writeIndex(index: IndexFile): Promise<void> {
859
+ await mkdir(this.dir, { recursive: true });
860
+ const path = join(this.dir, "index.json");
861
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
862
+ await writeFile(temporary, `${JSON.stringify(index, null, 2)}\n`);
863
+ await rename(temporary, path);
864
+ }
865
+
866
+ /** Built-in packs first, then added ones (an added name shadows none). */
867
+ public async list(): Promise<PackInfo[]> {
868
+ const index = await this.readIndex();
869
+ const added = Object.entries(index.packs)
870
+ .filter(
871
+ ([name]) =>
872
+ PACK_NAME.test(name) && !PACK_CATALOG.some((p) => p.name === name),
873
+ )
874
+ .map(([name, entry]) => this.fromIndex(name, entry))
875
+ .filter((entry): entry is PackInfo => entry !== undefined)
876
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
877
+ return [...PACK_CATALOG, ...added];
878
+ }
879
+
880
+ private fromIndex(
881
+ name: string,
882
+ entry: IndexFile["packs"][string],
883
+ ): PackInfo | undefined {
884
+ try {
885
+ return Object.freeze({
886
+ name,
887
+ title: String(entry.title ?? name).slice(0, 120),
888
+ source: String(entry.source),
889
+ manifestUrl: resolveManifestUrl(String(entry.source)),
890
+ license: String(entry.license ?? NO_LICENSE).slice(0, 64),
891
+ ...(entry.attribution
892
+ ? { attribution: String(entry.attribution).slice(0, 400) }
893
+ : {}),
894
+ ...(entry.homepage ? { homepage: String(entry.homepage) } : {}),
895
+ kind: entry.kind === "gm" ? "gm" : "strudel",
896
+ builtin: false,
897
+ });
898
+ } catch {
899
+ return undefined;
900
+ }
901
+ }
902
+
903
+ public async info(name: string): Promise<PackInfo | undefined> {
904
+ return (await this.list()).find((pack) => pack.name === name);
905
+ }
906
+
907
+ /**
908
+ * Registers a pack by manifest URL or `github:` shorthand, fetching its
909
+ * manifest once to check it. Returns the pack and its sound count.
910
+ */
911
+ public async add(
912
+ source: string,
913
+ options: Readonly<{
914
+ name?: string;
915
+ license?: string;
916
+ attribution?: string;
917
+ }> = {},
918
+ ): Promise<Readonly<{ pack: PackInfo; sounds: number }>> {
919
+ const manifestUrl = resolveManifestUrl(source);
920
+ checkFetchUrl(manifestUrl, this.allowLoopbackHttp);
921
+ const name = options.name ?? packNameFromSource(source);
922
+ if (!PACK_NAME.test(name))
923
+ throw new PackError(
924
+ `${name.slice(0, 40)} · pack names are lowercase a-z, 0-9, . _ -`,
925
+ );
926
+ if (PACK_CATALOG.some((pack) => pack.name === name))
927
+ throw new PackError(
928
+ `${name} is a built-in pack · pick another name with as <name>`,
929
+ );
930
+ const repo = GITHUB.exec(source.trim());
931
+ const pack: PackInfo = Object.freeze({
932
+ name,
933
+ title: name,
934
+ source: source.trim(),
935
+ manifestUrl,
936
+ license: options.license ?? NO_LICENSE,
937
+ ...(options.attribution ? { attribution: options.attribution } : {}),
938
+ ...(repo ? { homepage: `https://github.com/${repo[1]}/${repo[2]}` } : {}),
939
+ kind: /\/names\.json$/.test(manifestUrl) ? "gm" : "strudel",
940
+ builtin: false,
941
+ });
942
+ this.manifests.delete(name);
943
+ const manifest = await this.loadManifest(pack, { refresh: true });
944
+ const index = await this.readIndex();
945
+ const { builtin: _builtin, manifestUrl: _url, ...stored } = pack;
946
+ index.packs[name] = { ...stored, addedAt: new Date().toISOString() };
947
+ await this.writeIndex(index);
948
+ return { pack, sounds: manifest.sounds.size };
949
+ }
950
+
951
+ /** Forgets an added pack, or a built-in pack's cached manifest. */
952
+ public async remove(name: string): Promise<boolean> {
953
+ const index = await this.readIndex();
954
+ const builtin = PACK_CATALOG.some((pack) => pack.name === name);
955
+ const known = builtin || name in index.packs;
956
+ if (name in index.packs) {
957
+ delete index.packs[name];
958
+ await this.writeIndex(index);
959
+ }
960
+ this.manifests.delete(name);
961
+ await unlink(this.manifestPath(name)).catch(() => undefined);
962
+ return known;
963
+ }
964
+
965
+ // -- manifests ------------------------------------------------------------
966
+
967
+ private manifestPath(name: string): string {
968
+ return join(this.dir, "manifests", `${name}.json`);
969
+ }
970
+
971
+ public async manifest(name: string): Promise<Manifest> {
972
+ const pack = await this.info(name);
973
+ if (!pack)
974
+ throw new PackError(
975
+ `no pack named ${name.slice(0, 64)} · /pack list shows the packs`,
976
+ );
977
+ return this.loadManifest(pack);
978
+ }
979
+
980
+ private loadManifest(
981
+ pack: PackInfo,
982
+ options: Readonly<{ refresh?: boolean }> = {},
983
+ ): Promise<Manifest> {
984
+ const memo = this.manifests.get(pack.name);
985
+ if (memo && !options.refresh) return memo;
986
+ const promise = this.readManifest(pack, options.refresh === true);
987
+ this.manifests.set(pack.name, promise);
988
+ promise.catch(() => this.manifests.delete(pack.name));
989
+ return promise;
990
+ }
991
+
992
+ private async readManifest(
993
+ pack: PackInfo,
994
+ refresh: boolean,
995
+ ): Promise<Manifest> {
996
+ const path = this.manifestPath(pack.name);
997
+ const parse = (text: string) => {
998
+ const json: unknown = JSON.parse(text);
999
+ return pack.kind === "gm"
1000
+ ? parseGmNames(json, pack.manifestUrl)
1001
+ : parseManifest(json, pack.manifestUrl);
1002
+ };
1003
+ if (!refresh) {
1004
+ try {
1005
+ const cached = JSON.parse(await readFile(path, "utf8")) as {
1006
+ url?: string;
1007
+ body?: string;
1008
+ };
1009
+ if (cached.url === pack.manifestUrl && typeof cached.body === "string")
1010
+ return parse(cached.body);
1011
+ } catch {
1012
+ /* not cached yet */
1013
+ }
1014
+ }
1015
+ if (this.offline)
1016
+ throw new PackError(
1017
+ `${pack.name} · manifest is not cached and dawg is offline`,
1018
+ );
1019
+ const bytes = await fetchBounded(pack.manifestUrl, {
1020
+ maxBytes: PACK_LIMITS.maxManifestBytes,
1021
+ timeoutMs: this.timeoutMs,
1022
+ allowLoopbackHttp: this.allowLoopbackHttp,
1023
+ ...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
1024
+ });
1025
+ const body = new TextDecoder().decode(bytes);
1026
+ let manifest: Manifest;
1027
+ try {
1028
+ manifest = parse(body);
1029
+ } catch (error) {
1030
+ if (error instanceof PackError)
1031
+ throw new PackError(`${pack.name} · ${error.message}`);
1032
+ throw new PackError(`${pack.name} · manifest is not valid JSON`);
1033
+ }
1034
+ if (pack.aliasesUrl) await this.fetchAliases(pack);
1035
+ try {
1036
+ await mkdir(join(this.dir, "manifests"), { recursive: true });
1037
+ const temporary = `${path}.${process.pid}.tmp`;
1038
+ await writeFile(
1039
+ temporary,
1040
+ JSON.stringify({
1041
+ url: pack.manifestUrl,
1042
+ fetchedAt: new Date().toISOString(),
1043
+ body,
1044
+ }),
1045
+ );
1046
+ await rename(temporary, path);
1047
+ } catch {
1048
+ /* the cache is an optimisation */
1049
+ }
1050
+ return manifest;
1051
+ }
1052
+
1053
+ // -- bank nicknames -------------------------------------------------------
1054
+
1055
+ private aliasesPath(name: string): string {
1056
+ return join(this.dir, "manifests", `${name}.aliases.json`);
1057
+ }
1058
+
1059
+ /** Best effort: a missing or broken alias file keeps the snapshot. */
1060
+ private async fetchAliases(pack: PackInfo): Promise<void> {
1061
+ if (!pack.aliasesUrl || this.offline) return;
1062
+ try {
1063
+ const bytes = await fetchBounded(pack.aliasesUrl, {
1064
+ maxBytes: 256 * 1024,
1065
+ timeoutMs: this.timeoutMs,
1066
+ allowLoopbackHttp: this.allowLoopbackHttp,
1067
+ ...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
1068
+ });
1069
+ const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
1070
+ if (!json || typeof json !== "object" || Array.isArray(json)) return;
1071
+ const path = this.aliasesPath(pack.name);
1072
+ await mkdir(join(this.dir, "manifests"), { recursive: true });
1073
+ const temporary = `${path}.${process.pid}.tmp`;
1074
+ await writeFile(temporary, JSON.stringify(json));
1075
+ await rename(temporary, path);
1076
+ this.aliases.delete(pack.name);
1077
+ } catch {
1078
+ /* the shipped snapshot still answers */
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * A pack's bank nicknames: the shipped Strudel snapshot for packs that use
1084
+ * Strudel's alias file, overlaid with the copy fetched with the manifest.
1085
+ * Packs without nicknames get an empty lookup.
1086
+ */
1087
+ public async bankAliases(name: string): Promise<BankAliases> {
1088
+ const memo = this.aliases.get(name);
1089
+ if (memo) return memo;
1090
+ const pack = await this.info(name);
1091
+ let result = bankAliasIndex();
1092
+ if (pack?.aliasesUrl) {
1093
+ let fetched: Record<string, unknown> = {};
1094
+ try {
1095
+ const json: unknown = JSON.parse(
1096
+ await readFile(this.aliasesPath(name), "utf8"),
1097
+ );
1098
+ if (json && typeof json === "object" && !Array.isArray(json))
1099
+ fetched = json as Record<string, unknown>;
1100
+ } catch {
1101
+ /* not fetched yet */
1102
+ }
1103
+ result = bankAliasIndex(
1104
+ pack.aliasesUrl === BANK_ALIASES_URL ? STRUDEL_BANK_ALIASES : {},
1105
+ fetched,
1106
+ );
1107
+ }
1108
+ this.aliases.set(name, result);
1109
+ return result;
1110
+ }
1111
+
1112
+ // -- sounds ---------------------------------------------------------------
1113
+
1114
+ /** Resolves `pack:<pack>/<sound>[:<n>]` to one file URL (fetches the manifest if needed). */
1115
+ public async resolve(src: string | PackRef): Promise<ResolvedSound> {
1116
+ const ref = typeof src === "string" ? parsePackRef(src) : src;
1117
+ if (!ref)
1118
+ throw new PackError(
1119
+ `${String(src).slice(0, 80)} · use pack:<pack>/<sound>[:<n>]`,
1120
+ );
1121
+ const pack = await this.info(ref.pack);
1122
+ if (!pack)
1123
+ throw new PackError(
1124
+ `no pack named ${ref.pack} · /pack list shows the packs`,
1125
+ );
1126
+ const manifest = await this.loadManifest(pack);
1127
+ const soundName = await this.soundKey(pack.name, manifest, ref.sound);
1128
+ const sound =
1129
+ soundName === undefined ? undefined : manifest.sounds.get(soundName);
1130
+ if (!sound || soundName === undefined)
1131
+ throw new PackError(
1132
+ `${ref.pack} has no sound ${ref.sound} · /pack info ${ref.pack} lists them`,
1133
+ );
1134
+ let file: string;
1135
+ let root: number | undefined;
1136
+ if (sound.kind === "list") {
1137
+ const index = typeof ref.n === "number" ? ref.n : 0;
1138
+ file = sound.files[index % sound.files.length]!;
1139
+ } else {
1140
+ const wanted = typeof ref.n === "string" ? noteNumber(ref.n) : undefined;
1141
+ const zone =
1142
+ wanted !== undefined
1143
+ ? nearestZone(sound.zones, wanted)
1144
+ : sound.zones[(ref.n as number) % sound.zones.length]!;
1145
+ file = zone.file;
1146
+ root = zone.note;
1147
+ }
1148
+ const url = new URL(encodePath(file), manifest.base).href;
1149
+ checkFetchUrl(url, this.allowLoopbackHttp);
1150
+ const canonical: PackRef = Object.freeze({ ...ref, sound: soundName });
1151
+ return Object.freeze({
1152
+ pack: pack.name,
1153
+ sound: soundName,
1154
+ ref: canonical,
1155
+ src: formatPackRef(canonical),
1156
+ url,
1157
+ license: pack.license,
1158
+ ...(root !== undefined ? { root } : {}),
1159
+ });
1160
+ }
1161
+
1162
+ /**
1163
+ * The manifest key for `sound`: exact, then case-insensitive, then with a
1164
+ * bank nickname expanded (`TR909_bd` → `RolandTR909_bd`).
1165
+ */
1166
+ public async soundKey(
1167
+ pack: string,
1168
+ manifest: Manifest,
1169
+ sound: string,
1170
+ ): Promise<string | undefined> {
1171
+ if (manifest.sounds.has(sound)) return sound;
1172
+ const folded = caseKey(manifest, sound);
1173
+ if (folded !== undefined) return folded;
1174
+ const at = sound.lastIndexOf("_");
1175
+ if (at <= 0) return undefined;
1176
+ const bank = resolveBankAlias(
1177
+ sound.slice(0, at),
1178
+ await this.bankAliases(pack),
1179
+ );
1180
+ if (!bank) return undefined;
1181
+ const expanded = `${bank}${sound.slice(at)}`;
1182
+ return manifest.sounds.has(expanded)
1183
+ ? expanded
1184
+ : caseKey(manifest, expanded);
1185
+ }
1186
+
1187
+ /**
1188
+ * The bank `name` means in `pack`: a manifest bank (any case) or a
1189
+ * nickname of one. Undefined when neither.
1190
+ */
1191
+ public async bankKey(
1192
+ pack: string,
1193
+ manifest: Manifest,
1194
+ name: string,
1195
+ ): Promise<string | undefined> {
1196
+ const banks = banksOf(manifest);
1197
+ const lower = name.toLowerCase();
1198
+ const direct = banks.find((bank) => bank.toLowerCase() === lower);
1199
+ if (direct) return direct;
1200
+ const aliased = resolveBankAlias(name, await this.bankAliases(pack));
1201
+ if (!aliased) return undefined;
1202
+ return banks.find((bank) => bank.toLowerCase() === aliased.toLowerCase());
1203
+ }
1204
+
1205
+ /**
1206
+ * The bytes of one sample file: from `files/` when cached, else fetched
1207
+ * (once, even when many voices ask at the same time). Enforces the sample
1208
+ * file size limit. A pinned `sha256` that no longer matches is an error.
1209
+ */
1210
+ public fetchFile(url: string, expectSha256?: string): Promise<FetchedFile> {
1211
+ const key = `${url}\u0000${expectSha256 ?? ""}`;
1212
+ const running = this.inflight.get(key);
1213
+ if (running) return running;
1214
+ const promise = this.fetchFileOnce(url, expectSha256).finally(() =>
1215
+ this.inflight.delete(key),
1216
+ );
1217
+ this.inflight.set(key, promise);
1218
+ return promise;
1219
+ }
1220
+
1221
+ private async fetchFileOnce(
1222
+ url: string,
1223
+ expectSha256?: string,
1224
+ ): Promise<FetchedFile> {
1225
+ checkFetchUrl(url, this.allowLoopbackHttp);
1226
+ const filesDir = join(this.dir, "files");
1227
+ const path = this.filePath(url);
1228
+ try {
1229
+ const bytes = new Uint8Array(await readFile(path));
1230
+ const sha256 = sha256Hex(bytes);
1231
+ if (!expectSha256 || sha256 === expectSha256) {
1232
+ const now = new Date();
1233
+ await utimes(path, now, now).catch(() => undefined);
1234
+ return { url, sha256, bytes, path, cached: true };
1235
+ }
1236
+ } catch {
1237
+ /* not cached */
1238
+ }
1239
+ if (this.offline)
1240
+ throw new PackError(`${shortUrl(url)} · not cached and dawg is offline`);
1241
+ const bytes = await fetchBounded(url, {
1242
+ maxBytes: SCORE_LIMITS.maxSampleFileBytes,
1243
+ timeoutMs: this.timeoutMs,
1244
+ allowLoopbackHttp: this.allowLoopbackHttp,
1245
+ ...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
1246
+ });
1247
+ if (bytes.byteLength === 0)
1248
+ throw new PackError(`${shortUrl(url)} · empty file`);
1249
+ const sha256 = sha256Hex(bytes);
1250
+ if (expectSha256 && sha256 !== expectSha256)
1251
+ throw new PackError(
1252
+ `${shortUrl(url)} · changed upstream (sha256 ${sha256.slice(0, 12)}… ≠ pinned ${expectSha256.slice(0, 12)}…) · re-pick the sound to pin the new file`,
1253
+ );
1254
+ try {
1255
+ await mkdir(filesDir, { recursive: true });
1256
+ const temporary = `${path}.${process.pid}.tmp`;
1257
+ await writeFile(temporary, bytes);
1258
+ await rename(temporary, path);
1259
+ await this.pruneCache(this.maxFileCacheBytes, path);
1260
+ } catch {
1261
+ /* read-only cache: still usable this once from memory */
1262
+ }
1263
+ return { url, sha256, bytes, path, cached: false };
1264
+ }
1265
+
1266
+ /**
1267
+ * Resolves and fetches a pack sound and returns the pinned `SampleRef`
1268
+ * (src, sha256, url, license, and root for keyed zones).
1269
+ */
1270
+ public async pin(
1271
+ src: string | PackRef,
1272
+ extra: Partial<SampleRef> = {},
1273
+ ): Promise<SampleRef> {
1274
+ const resolved = await this.resolve(src);
1275
+ const file = await this.fetchFile(resolved.url);
1276
+ return Object.freeze({
1277
+ ...extra,
1278
+ src: resolved.src,
1279
+ sha256: file.sha256,
1280
+ url: resolved.url,
1281
+ license: resolved.license,
1282
+ ...(resolved.root !== undefined && extra.root === undefined
1283
+ ? { root: resolved.root }
1284
+ : {}),
1285
+ });
1286
+ }
1287
+
1288
+ /**
1289
+ * Sounds whose name contains every query word, across all packs whose
1290
+ * manifests are cached or fetchable. Packs that fail to load are skipped.
1291
+ */
1292
+ public async search(
1293
+ query: string,
1294
+ options: Readonly<{ pack?: string; limit?: number }> = {},
1295
+ ): Promise<SoundMatch[]> {
1296
+ const words = query
1297
+ .toLowerCase()
1298
+ .split(/[\s/:]+/)
1299
+ .filter(Boolean);
1300
+ const limit = Math.max(1, Math.min(options.limit ?? 50, 500));
1301
+ const out: SoundMatch[] = [];
1302
+ for (const pack of await this.list()) {
1303
+ if (options.pack && pack.name !== options.pack) continue;
1304
+ let manifest: Manifest;
1305
+ try {
1306
+ manifest = await this.loadManifest(pack);
1307
+ } catch {
1308
+ continue;
1309
+ }
1310
+ for (const [name, sound] of manifest.sounds) {
1311
+ const haystack = `${pack.name}/${name}`.toLowerCase();
1312
+ if (!words.every((word) => haystack.includes(word))) continue;
1313
+ out.push({
1314
+ pack: pack.name,
1315
+ sound: name,
1316
+ src: `pack:${pack.name}/${name}`,
1317
+ kind: sound.kind === "zones" ? "keyed" : "oneshot",
1318
+ count:
1319
+ sound.kind === "zones" ? sound.zones.length : sound.files.length,
1320
+ license: pack.license,
1321
+ });
1322
+ if (out.length >= limit) return out;
1323
+ }
1324
+ }
1325
+ return out;
1326
+ }
1327
+ }
1328
+
1329
+ export type SoundMatch = Readonly<{
1330
+ pack: string;
1331
+ sound: string;
1332
+ src: string;
1333
+ /** `keyed` sounds are pitched instruments (note zones). */
1334
+ kind: "oneshot" | "keyed";
1335
+ /** Files (oneshot) or zones (keyed). */
1336
+ count: number;
1337
+ license: string;
1338
+ }>;
1339
+
1340
+ function caseKey(manifest: Manifest, sound: string): string | undefined {
1341
+ const lower = sound.toLowerCase();
1342
+ for (const key of manifest.sounds.keys())
1343
+ if (key.toLowerCase() === lower) return key;
1344
+ return undefined;
1345
+ }
1346
+
1347
+ function nearestZone(
1348
+ zones: readonly ManifestZone[],
1349
+ note: number,
1350
+ ): ManifestZone {
1351
+ let best = zones[0]!;
1352
+ for (const zone of zones)
1353
+ if (Math.abs(zone.note - note) < Math.abs(best.note - note)) best = zone;
1354
+ return best;
1355
+ }
1356
+
1357
+ /** Percent-encodes characters a manifest path may carry raw (spaces, #, ?). */
1358
+ function encodePath(file: string): string {
1359
+ return file
1360
+ .split("/")
1361
+ .map((part) => {
1362
+ try {
1363
+ return encodeURIComponent(decodeURIComponent(part));
1364
+ } catch {
1365
+ return encodeURIComponent(part);
1366
+ }
1367
+ })
1368
+ .join("/");
1369
+ }
1370
+
1371
+ const PACK_FILE = /^[0-9a-f]{64}\.bin$/;
1372
+ const PROTECTED = new Map<string, Set<string>>();
1373
+
1374
+ // ---------------------------------------------------------------------------
1375
+ // Kits and instruments
1376
+
1377
+ /** Ready-made banks `/kit` knows by short name; all resolve through packs. */
1378
+ export const DEFAULT_KITS: Readonly<
1379
+ Record<string, Readonly<{ pack: string; bank: string }>>
1380
+ > = Object.freeze({
1381
+ "909": { pack: "tidal-drum-machines", bank: "RolandTR909" },
1382
+ "808": { pack: "tidal-drum-machines", bank: "RolandTR808" },
1383
+ "707": { pack: "tidal-drum-machines", bank: "RolandTR707" },
1384
+ "606": { pack: "tidal-drum-machines", bank: "RolandTR606" },
1385
+ linn: { pack: "tidal-drum-machines", bank: "LinnDrum" },
1386
+ lm1: { pack: "tidal-drum-machines", bank: "LinnLM1" },
1387
+ dmx: { pack: "tidal-drum-machines", bank: "OberheimDMX" },
1388
+ cr78: { pack: "tidal-drum-machines", bank: "RolandCompurhythm78" },
1389
+ uzu: { pack: "uzu-drumkit", bank: "" },
1390
+ dirt: { pack: "dirt-samples", bank: "" },
1391
+ });
1392
+
1393
+ /** Soundfont instruments the menu offers first (Strudel `gm_` naming). */
1394
+ export const GM_INSTRUMENTS: readonly string[] = Object.freeze([
1395
+ "gm_acoustic_grand_piano",
1396
+ "gm_electric_piano_1",
1397
+ "gm_electric_piano_2",
1398
+ "gm_vibraphone",
1399
+ "gm_marimba",
1400
+ "gm_drawbar_organ",
1401
+ "gm_acoustic_guitar_nylon",
1402
+ "gm_electric_guitar_clean",
1403
+ "gm_acoustic_bass",
1404
+ "gm_electric_bass_finger",
1405
+ "gm_synth_bass_1",
1406
+ "gm_string_ensemble_1",
1407
+ "gm_choir_aahs",
1408
+ "gm_trumpet",
1409
+ "gm_flute",
1410
+ "gm_lead_1_square",
1411
+ "gm_pad_2_warm",
1412
+ ]);
1413
+
1414
+ /** The kit `/kit` uses with no bank. */
1415
+ export const DEFAULT_KIT = "909";
1416
+
1417
+ /** The pack Strudel's bank nicknames point into. */
1418
+ export const ALIASED_PACK = "tidal-drum-machines";
1419
+
1420
+ export type Kit = Readonly<{
1421
+ pack: string;
1422
+ /** Manifest bank prefix (`RolandTR909`), empty for bank-less packs. */
1423
+ bank: string;
1424
+ license: string;
1425
+ /** dawg drum voice → pack sound src (`pack:tidal-drum-machines/RolandTR909_bd`). */
1426
+ voices: ReadonlyMap<DrumVoice, string>;
1427
+ }>;
1428
+
1429
+ /** Sound suffixes per drum voice, best first (Tidal/Strudel naming). */
1430
+ const KIT_SOUNDS: Readonly<Record<DrumVoice, readonly string[]>> =
1431
+ Object.freeze({
1432
+ kick: ["bd"],
1433
+ snare: ["sd", "sn"],
1434
+ clap: ["cp"],
1435
+ rim: ["rim", "rs", "cb"],
1436
+ tom: ["mt", "lt", "ht"],
1437
+ hat: ["hh", "ch"],
1438
+ openhat: ["oh", "ho"],
1439
+ });
1440
+
1441
+ /**
1442
+ * A drum kit from a Strudel-style bank (`RolandTR909`, `TR909`, `tr909`,
1443
+ * `909`, `sp12`, `uzu`, …): the pack sound each dawg drum voice plays.
1444
+ * Resolution order: a Strudel nickname in its exact case (`TR909`, `Linn` →
1445
+ * AkaiLinn), dawg's short names (`909`, `linn` → LinnDrum), then per pack a
1446
+ * bank name in any case, a nickname in any case (`sp12` → EmuSP12), and a
1447
+ * bank suffix. Banks match the `<bank>_<sound>` manifest keys;
1448
+ * `bank("RolandTR909")` in Strudel and `/kit RolandTR909` pick the same files.
1449
+ * Undefined when no pack has the bank.
1450
+ */
1451
+ export async function kitFromBank(
1452
+ bank: string,
1453
+ store: PackStore = new PackStore(),
1454
+ options: Readonly<{ pack?: string }> = {},
1455
+ ): Promise<Kit | undefined> {
1456
+ const wanted = bank.trim();
1457
+ // Strudel nicknames in their exact case (`TR909`, `Linn`) name the same
1458
+ // bank they do in Strudel; then dawg's short names (`909`, `linn`).
1459
+ const exactAlias = options.pack
1460
+ ? undefined
1461
+ : (await store.bankAliases(ALIASED_PACK)).exact.get(wanted);
1462
+ const preset = options.pack
1463
+ ? undefined
1464
+ : exactAlias
1465
+ ? { pack: ALIASED_PACK, bank: exactAlias }
1466
+ : DEFAULT_KITS[wanted.toLowerCase()];
1467
+ const packs = await store.list();
1468
+ const ordered = options.pack
1469
+ ? packs.filter((p) => p.name === options.pack)
1470
+ : preset
1471
+ ? packs.filter((p) => p.name === preset.pack)
1472
+ : [
1473
+ ...packs.filter((p) => p.name === "tidal-drum-machines"),
1474
+ ...packs.filter(
1475
+ (p) => p.name !== "tidal-drum-machines" && p.kind === "strudel",
1476
+ ),
1477
+ ];
1478
+ let firstError: unknown;
1479
+ let loaded = 0;
1480
+ for (const pack of ordered) {
1481
+ let manifest: Manifest;
1482
+ try {
1483
+ manifest = await store.manifest(pack.name);
1484
+ loaded += 1;
1485
+ } catch (error) {
1486
+ firstError ??= error;
1487
+ continue;
1488
+ }
1489
+ const prefix = preset
1490
+ ? preset.bank
1491
+ : wanted.toLowerCase() === pack.name && !banksOf(manifest).length
1492
+ ? ""
1493
+ : ((await store.bankKey(pack.name, manifest, wanted)) ??
1494
+ findBank(manifest, wanted));
1495
+ if (prefix === undefined) continue;
1496
+ const voices = new Map<DrumVoice, string>();
1497
+ for (const info of DRUM_VOICES) {
1498
+ for (const suffix of KIT_SOUNDS[info.voice]) {
1499
+ const key = prefix ? `${prefix}_${suffix}` : suffix;
1500
+ if (manifest.sounds.get(key)?.kind === "list") {
1501
+ voices.set(info.voice, `pack:${pack.name}/${key}`);
1502
+ break;
1503
+ }
1504
+ }
1505
+ }
1506
+ if (voices.size === 0) continue;
1507
+ return Object.freeze({
1508
+ pack: pack.name,
1509
+ bank: prefix,
1510
+ license: pack.license,
1511
+ voices,
1512
+ });
1513
+ }
1514
+ // Nothing matched and no pack could load (offline, say): say why.
1515
+ if (firstError && (preset || options.pack || loaded === 0)) throw firstError;
1516
+ return undefined;
1517
+ }
1518
+
1519
+ /** All `<bank>_` prefixes a pack's manifest has with at least a kick or snare. */
1520
+ export function banksOf(manifest: Manifest): string[] {
1521
+ const banks = new Set<string>();
1522
+ for (const key of manifest.sounds.keys()) {
1523
+ const at = key.lastIndexOf("_");
1524
+ if (at <= 0) continue;
1525
+ const suffix = key.slice(at + 1);
1526
+ if (parseDrumVoice(suffix) === "kick" || parseDrumVoice(suffix) === "snare")
1527
+ banks.add(key.slice(0, at));
1528
+ }
1529
+ return [...banks].sort();
1530
+ }
1531
+
1532
+ function findBank(manifest: Manifest, wanted: string): string | undefined {
1533
+ const banks = banksOf(manifest);
1534
+ const lower = wanted.toLowerCase();
1535
+ return (
1536
+ banks.find((bank) => bank.toLowerCase() === lower) ??
1537
+ banks.find((bank) => bank.toLowerCase().endsWith(lower))
1538
+ );
1539
+ }
1540
+
1541
+ /**
1542
+ * The sampler voices for a kit: one oneshot voice per drum voice, named like
1543
+ * dawg drum voices, each pinned to its file.
1544
+ */
1545
+ export async function pinKit(kit: Kit, store: PackStore): Promise<Sampler> {
1546
+ const voices: Record<string, SampleRef> = {};
1547
+ const entries = [...kit.voices];
1548
+ const pinned = await Promise.all(entries.map(([, src]) => store.pin(src)));
1549
+ entries.forEach(([voice], index) => {
1550
+ voices[voice] = pinned[index]!;
1551
+ });
1552
+ return Object.freeze({ mode: "oneshot", voices: Object.freeze(voices) });
1553
+ }
1554
+
1555
+ /**
1556
+ * A keyed sampler for a pitched pack sound (`gm_acoustic_grand_piano`,
1557
+ * `piano`, …): one voice per note zone, thinned evenly to the voice limit,
1558
+ * each pinned with its root note. List sounds become one keyed voice at C4.
1559
+ */
1560
+ export async function pinInstrument(
1561
+ pack: string,
1562
+ sound: string,
1563
+ store: PackStore,
1564
+ options: Readonly<{ maxZones?: number }> = {},
1565
+ ): Promise<Sampler> {
1566
+ const manifest = await store.manifest(pack);
1567
+ const key = manifest.sounds.has(sound) ? sound : caseKey(manifest, sound);
1568
+ const entry = key === undefined ? undefined : manifest.sounds.get(key);
1569
+ if (!entry || key === undefined)
1570
+ throw new PackError(`${pack} has no sound ${sound}`);
1571
+ if (entry.kind === "list") {
1572
+ const ref = await store.pin({ pack, sound: key, n: 0 }, { root: 60 });
1573
+ return Object.freeze({ mode: "keyed", voices: Object.freeze({ s0: ref }) });
1574
+ }
1575
+ const zones = thinZones(
1576
+ entry.zones,
1577
+ options.maxZones ?? defaultZoneCount(entry.zones),
1578
+ );
1579
+ const refs = await mapLimit(zones, 6, (zone) =>
1580
+ store.pin({ pack, sound: key, n: zone.name }),
1581
+ );
1582
+ const voices: Record<string, SampleRef> = {};
1583
+ zones.forEach((zone, index) => {
1584
+ voices[`z${String(zone.note).padStart(3, "0")}`] = refs[index]!;
1585
+ });
1586
+ return Object.freeze({ mode: "keyed", voices: Object.freeze(voices) });
1587
+ }
1588
+
1589
+ /** Dense soundfonts (a file per semitone) keep every third note. */
1590
+ function defaultZoneCount(zones: readonly ManifestZone[]): number {
1591
+ return Math.min(
1592
+ PACK_LIMITS.maxZones,
1593
+ zones.length > 40 ? Math.ceil(zones.length / 3) : zones.length,
1594
+ );
1595
+ }
1596
+
1597
+ export function thinZones(
1598
+ zones: readonly ManifestZone[],
1599
+ max: number,
1600
+ ): ManifestZone[] {
1601
+ const limit = Math.max(1, Math.min(max, PACK_LIMITS.maxZones));
1602
+ if (zones.length <= limit) return [...zones];
1603
+ const out: ManifestZone[] = [];
1604
+ for (let index = 0; index < limit; index += 1)
1605
+ out.push(
1606
+ zones[Math.round((index * (zones.length - 1)) / (limit - 1 || 1))]!,
1607
+ );
1608
+ return out.filter((zone, index) => index === 0 || zone !== out[index - 1]);
1609
+ }
1610
+
1611
+ async function mapLimit<T, R>(
1612
+ items: readonly T[],
1613
+ limit: number,
1614
+ run: (item: T) => Promise<R>,
1615
+ ): Promise<R[]> {
1616
+ const out: R[] = new Array(items.length);
1617
+ let next = 0;
1618
+ const workers = Array.from(
1619
+ { length: Math.min(limit, items.length) },
1620
+ async () => {
1621
+ while (next < items.length) {
1622
+ const index = next++;
1623
+ out[index] = await run(items[index]!);
1624
+ }
1625
+ },
1626
+ );
1627
+ await Promise.all(workers);
1628
+ return out;
1629
+ }
1630
+
1631
+ // ---------------------------------------------------------------------------
1632
+ // Credits
1633
+
1634
+ export type PackCredit = Readonly<{
1635
+ pack: string;
1636
+ license: string;
1637
+ attribution?: string;
1638
+ homepage?: string;
1639
+ sounds: readonly string[];
1640
+ }>;
1641
+
1642
+ /** Packs a score's sampler voices use, with their licenses. */
1643
+ export function packCredits(
1644
+ voices: Iterable<SampleRef>,
1645
+ packs: readonly PackInfo[] = PACK_CATALOG,
1646
+ ): PackCredit[] {
1647
+ const byPack = new Map<string, { license: string; sounds: Set<string> }>();
1648
+ for (const ref of voices) {
1649
+ const parsed = parsePackRef(ref.src);
1650
+ if (!parsed) continue;
1651
+ const entry = byPack.get(parsed.pack) ?? {
1652
+ license:
1653
+ ref.license ??
1654
+ packs.find((p) => p.name === parsed.pack)?.license ??
1655
+ NO_LICENSE,
1656
+ sounds: new Set<string>(),
1657
+ };
1658
+ entry.sounds.add(parsed.sound);
1659
+ byPack.set(parsed.pack, entry);
1660
+ }
1661
+ return [...byPack]
1662
+ .sort(([a], [b]) => (a < b ? -1 : 1))
1663
+ .map(([pack, entry]) => {
1664
+ const info = packs.find((p) => p.name === pack);
1665
+ return Object.freeze({
1666
+ pack,
1667
+ license: entry.license,
1668
+ ...(info?.attribution ? { attribution: info.attribution } : {}),
1669
+ ...(info?.homepage ? { homepage: info.homepage } : {}),
1670
+ sounds: Object.freeze([...entry.sounds].sort()),
1671
+ });
1672
+ });
1673
+ }
1674
+
1675
+ /** True for licenses that require attribution (CC-BY, CC-BY-SA). */
1676
+ export function needsAttribution(license: string): boolean {
1677
+ return /^CC-BY(-SA)?-/.test(license);
1678
+ }
1679
+
1680
+ /**
1681
+ * `CREDITS.md` text for the attribution-requiring packs a score uses, or
1682
+ * undefined when none need credit.
1683
+ */
1684
+ export function creditsMarkdown(
1685
+ credits: readonly PackCredit[],
1686
+ ): string | undefined {
1687
+ const needed = credits.filter((credit) => needsAttribution(credit.license));
1688
+ if (needed.length === 0) return undefined;
1689
+ const lines = [
1690
+ "# Credits",
1691
+ "",
1692
+ "This project uses samples that require attribution. Keep these lines with music you release.",
1693
+ "",
1694
+ ];
1695
+ for (const credit of needed) {
1696
+ lines.push(
1697
+ `- **${credit.pack}** (${credit.license}): ${credit.attribution ?? credit.homepage ?? credit.pack}`,
1698
+ );
1699
+ lines.push(
1700
+ ` - sounds: ${credit.sounds.slice(0, 40).join(", ")}${credit.sounds.length > 40 ? ", …" : ""}`,
1701
+ );
1702
+ }
1703
+ lines.push(
1704
+ "",
1705
+ "Written by dawg from the pack metadata pinned in the tracks.",
1706
+ "",
1707
+ );
1708
+ return lines.join("\n");
1709
+ }
1710
+
1711
+ /** Writes or removes `<projectRoot>/CREDITS.md` to match the score's packs. */
1712
+ export async function writeCredits(
1713
+ projectRoot: string,
1714
+ credits: readonly PackCredit[],
1715
+ ): Promise<"written" | "unchanged" | "none"> {
1716
+ const text = creditsMarkdown(credits);
1717
+ if (text === undefined) return "none";
1718
+ const path = join(projectRoot, "CREDITS.md");
1719
+ const current = await readFile(path, "utf8").catch(() => undefined);
1720
+ if (current === text) return "unchanged";
1721
+ if (
1722
+ current !== undefined &&
1723
+ !current.includes("Written by dawg from the pack metadata")
1724
+ )
1725
+ return "unchanged"; // the user's own CREDITS.md; leave it alone
1726
+ const temporary = `${path}.${process.pid}.tmp`;
1727
+ await writeFile(temporary, text);
1728
+ await rename(temporary, path);
1729
+ return "written";
1730
+ }
1731
+
1732
+ /** Every pinned check a score-stored ref needs before it can be fetched. */
1733
+ export function isFetchablePin(ref: SampleRef): boolean {
1734
+ return ref.url !== undefined && isPinnedSampleUrl(ref.url);
1735
+ }
1736
+
1737
+ /** One line naming every pack a render uses, attribution first. */
1738
+ export function creditsLine(
1739
+ credits: readonly PackCredit[],
1740
+ ): string | undefined {
1741
+ if (credits.length === 0) return undefined;
1742
+ return credits
1743
+ .map((credit) =>
1744
+ needsAttribution(credit.license) && credit.attribution
1745
+ ? credit.attribution
1746
+ : `${credit.pack} (${credit.license})`,
1747
+ )
1748
+ .join("; ");
1749
+ }
1750
+
1751
+ /**
1752
+ * A copy of a RIFF/WAVE file with a `LIST/INFO` chunk carrying `ICMT`
1753
+ * (comment) appended after the existing chunks; readers that do not know it
1754
+ * skip it. Unchanged when `bytes` is not RIFF/WAVE.
1755
+ */
1756
+ export function withWavComment(bytes: Uint8Array, comment: string): Uint8Array {
1757
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1758
+ const tag = (offset: number) =>
1759
+ String.fromCharCode(
1760
+ bytes[offset]!,
1761
+ bytes[offset + 1]!,
1762
+ bytes[offset + 2]!,
1763
+ bytes[offset + 3]!,
1764
+ );
1765
+ if (bytes.byteLength < 12 || tag(0) !== "RIFF" || tag(8) !== "WAVE")
1766
+ return bytes;
1767
+ const text = new TextEncoder().encode(`${comment.slice(0, 4000)}\0`);
1768
+ const padded = text.byteLength + (text.byteLength % 2);
1769
+ const listSize = 4 + 8 + padded;
1770
+ const base = bytes.byteLength + (bytes.byteLength % 2);
1771
+ const out = new Uint8Array(base + 8 + listSize);
1772
+ out.set(bytes);
1773
+ const o = new DataView(out.buffer);
1774
+ const put = (offset: number, value: string) => {
1775
+ for (let index = 0; index < 4; index += 1)
1776
+ out[offset + index] = value.charCodeAt(index);
1777
+ };
1778
+ put(base, "LIST");
1779
+ o.setUint32(base + 4, listSize, true);
1780
+ put(base + 8, "INFO");
1781
+ put(base + 12, "ICMT");
1782
+ o.setUint32(base + 16, text.byteLength, true);
1783
+ out.set(text, base + 20);
1784
+ o.setUint32(4, out.byteLength - 8, true);
1785
+ void view;
1786
+ return out;
1787
+ }