@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
@@ -7,9 +7,10 @@
7
7
  * int, `sowt`, `fl32`) decode natively in TypeScript.
8
8
  * - Anything else (mp3, flac, ogg, m4a, …) decodes through `ffmpeg` when it is
9
9
  * on PATH; dawg never installs it.
10
- * - Decoded PCM is cached content-addressed at `.dawg/assets/<sha256>.pcm`
11
- * (and in memory), least recently used first out, bounded by
12
- * `SCORE_LIMITS.maxSampleCacheBytes`.
10
+ * - Decoded PCM is cached content-addressed at `.dawg/assets/<sha256>.pcm`,
11
+ * least recently used first out, bounded by `DAWG_ASSETS_CACHE_MAX`
12
+ * (default 1 GiB, see `cache.ts`); files the loaded score uses are never
13
+ * evicted. In memory, decoded voices share a 512 MiB LRU.
13
14
  *
14
15
  * Loading never throws for project problems: a voice that cannot load is
15
16
  * reported as a `<what> · <why> · <next step>` problem and renders silent.
@@ -17,20 +18,32 @@
17
18
  import { createHash } from "node:crypto";
18
19
  import {
19
20
  mkdir,
20
- readdir,
21
21
  readFile,
22
22
  rename,
23
23
  stat,
24
- unlink,
25
24
  utimes,
26
25
  writeFile,
27
26
  } from "node:fs/promises";
28
27
  import { join } from "node:path";
29
28
  import {
29
+ PACK_PREFIX,
30
30
  SCORE_LIMITS,
31
31
  isSamplerInstrument,
32
+ isLocalTableSrc,
33
+ isWavetableInstrument,
34
+ type SampleRef,
32
35
  type TrackScore,
33
36
  } from "../../core/score.ts";
37
+ import { PackError, PackStore } from "./packs.ts";
38
+ import {
39
+ DEFAULT_MEMORY_CACHE_BYTES,
40
+ assetsCacheMax,
41
+ cacheUsage,
42
+ pruneLru,
43
+ type CacheUsage,
44
+ type PruneResult,
45
+ } from "./cache.ts";
46
+ import { Wavetable, wavetableFromWav } from "./wavetable.ts";
34
47
  import { trackDirectories } from "../../core/sdk/print.ts";
35
48
  import { WavFormatError, parseWav } from "../media/vendor/wav.ts";
36
49
  import { WorkspaceError, resolveReadPath } from "../agent/workspace.ts";
@@ -58,6 +71,10 @@ export type SampleProblem = Readonly<{
58
71
  export type SampleBank = Readonly<{
59
72
  voices: ReadonlyMap<string, DecodedSample>;
60
73
  problems: readonly SampleProblem[];
74
+ /** Pack wavetables keyed by track id (built-in tables need no loading). */
75
+ wavetables?: ReadonlyMap<string, Wavetable>;
76
+ /** Decoded `reverb.ir` samples keyed by track id (built-ins need none). */
77
+ irs?: ReadonlyMap<string, DecodedSample>;
61
78
  }>;
62
79
 
63
80
  export const EMPTY_SAMPLE_BANK: SampleBank = Object.freeze({
@@ -65,16 +82,51 @@ export const EMPTY_SAMPLE_BANK: SampleBank = Object.freeze({
65
82
  problems: Object.freeze([]),
66
83
  });
67
84
 
68
- /** True when any track of the score is a sampler with voices. */
85
+ /**
86
+ * True when the score needs the sample library: a sampler with voices, or a
87
+ * wavetable track playing a pack table.
88
+ */
69
89
  export function hasSamplerTracks(score: TrackScore): boolean {
70
90
  return score.tracks.some(
71
91
  (track) =>
72
- isSamplerInstrument(track.instrument) &&
73
- track.sampler !== undefined &&
74
- Object.keys(track.sampler.voices).length > 0,
92
+ (isSamplerInstrument(track.instrument) &&
93
+ track.sampler !== undefined &&
94
+ Object.keys(track.sampler.voices).length > 0) ||
95
+ packWavetable(track) !== undefined ||
96
+ localWavetable(track) !== undefined ||
97
+ reverbIrSample(track) !== undefined,
75
98
  );
76
99
  }
77
100
 
101
+ /** The project WAV a wavetable track plays, if any. */
102
+ export function localWavetable(
103
+ track: TrackScore["tracks"][number],
104
+ ): SampleRef | undefined {
105
+ if (!isWavetableInstrument(track.instrument)) return undefined;
106
+ const table = track.wavetable?.table;
107
+ return table && isLocalTableSrc(table.src) ? table : undefined;
108
+ }
109
+
110
+ /** The pack sound or project file a track's reverb convolves with, if any. */
111
+ export function reverbIrSample(
112
+ track: TrackScore["tracks"][number],
113
+ ): SampleRef | undefined {
114
+ const ir = track.reverb?.ir;
115
+ return ir && !ir.src.startsWith("builtin:") ? ir : undefined;
116
+ }
117
+
118
+ /** The pinned pack table a wavetable track plays, if any. */
119
+ export function packWavetable(
120
+ track: TrackScore["tracks"][number],
121
+ ): SampleRef | undefined {
122
+ if (!isWavetableInstrument(track.instrument)) return undefined;
123
+ const table = track.wavetable?.table;
124
+ return table?.src.startsWith(PACK_PREFIX) ? table : undefined;
125
+ }
126
+
127
+ /** Parsed pack wavetables kept in memory (they are small). */
128
+ const MAX_MEMORY_WAVETABLES = 32;
129
+
78
130
  export function sampleKey(trackId: string, voice: string): string {
79
131
  return `${trackId}\u0000${voice}`;
80
132
  }
@@ -409,37 +461,25 @@ export function decodeCachedPcm(bytes: Uint8Array): PcmData | undefined {
409
461
  return { sampleRate, channels, frames: data.length / channels, data };
410
462
  }
411
463
 
412
- /** Delete least recently used `.pcm` files until the directory fits `maxBytes`. */
464
+ const ASSET_FILE = /^[0-9a-f]{64}\.pcm$/;
465
+
466
+ /**
467
+ * Delete least recently used `.pcm` files until the directory fits
468
+ * `maxBytes`, never the ones `keep` names (a sha256 or a set of them).
469
+ * Returns how many files went.
470
+ */
413
471
  export async function pruneAssetCache(
414
472
  dir: string,
415
473
  maxBytes: number,
416
- keep?: string,
474
+ keep?: string | ReadonlySet<string>,
417
475
  ): Promise<number> {
418
- let names: string[];
419
- try {
420
- names = await readdir(dir);
421
- } catch {
422
- return 0;
423
- }
424
- const entries: { path: string; bytes: number; used: number }[] = [];
425
- for (const name of names) {
426
- if (!/^[0-9a-f]{64}\.pcm$/.test(name)) continue;
427
- const path = join(dir, name);
428
- const info = await stat(path).catch(() => undefined);
429
- if (info?.isFile())
430
- entries.push({ path, bytes: info.size, used: info.mtimeMs });
431
- }
432
- let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
433
- entries.sort((a, b) => a.used - b.used || a.path.localeCompare(b.path));
434
- let removed = 0;
435
- for (const entry of entries) {
436
- if (total <= maxBytes) break;
437
- if (keep && entry.path.endsWith(`${keep}.pcm`)) continue;
438
- await unlink(entry.path).catch(() => undefined);
439
- total -= entry.bytes;
440
- removed += 1;
441
- }
442
- return removed;
476
+ const kept =
477
+ keep === undefined
478
+ ? () => false
479
+ : typeof keep === "string"
480
+ ? (name: string) => name === `${keep}.pcm`
481
+ : (name: string) => keep.has(name.slice(0, 64));
482
+ return (await pruneLru(dir, ASSET_FILE, maxBytes, kept)).removed;
443
483
  }
444
484
 
445
485
  // ---------------------------------------------------------------------------
@@ -450,11 +490,16 @@ export type SampleLibraryOptions = Readonly<{
450
490
  projectRoot: string;
451
491
  /** Default `<projectRoot>/.dawg/assets`. */
452
492
  cacheDir?: string;
453
- /** Disk and memory cache budget; default `SCORE_LIMITS.maxSampleCacheBytes`. */
493
+ /**
494
+ * Disk cache budget; default `assetsCacheMax()` (`DAWG_ASSETS_CACHE_MAX`,
495
+ * 1 GiB). Memory uses the smaller of this and 512 MiB.
496
+ */
454
497
  maxCacheBytes?: number;
455
498
  /** ffmpeg binary, `null` for none; default `Bun.which("ffmpeg")`. */
456
499
  ffmpeg?: string | null;
457
500
  runFfmpeg?: FfmpegRunner;
501
+ /** Resolves `pack:` voices; default a `PackStore` on the user cache. */
502
+ packs?: PackStore;
458
503
  }>;
459
504
 
460
505
  type MemoryEntry = { sample: DecodedSample; bytes: number };
@@ -466,28 +511,41 @@ type MemoryEntry = { sample: DecodedSample; bytes: number };
466
511
  export class SampleLibrary implements SampleSource {
467
512
  public readonly projectRoot: string;
468
513
  public readonly cacheDir: string;
469
- private readonly maxCacheBytes: number;
514
+ public readonly maxCacheBytes: number;
515
+ private readonly maxMemoryBytes: number;
516
+ /** Content hashes the last loaded score uses; pruning skips them. */
517
+ private inUse = new Set<string>();
470
518
  private readonly ffmpeg: string | null;
471
519
  private readonly runFfmpeg: FfmpegRunner | undefined;
472
520
  private readonly memory = new Map<string, MemoryEntry>();
473
521
  private memoryBytes = 0;
474
522
  /** `real:size:mtime` → sha256, so unchanged files are not re-hashed. */
475
523
  private readonly hashes = new Map<string, string>();
524
+ private readonly tables = new Map<string, Wavetable>();
476
525
  private stats = { decodes: 0, diskHits: 0, memoryHits: 0 };
477
526
 
478
527
  public constructor(options: SampleLibraryOptions) {
479
528
  this.projectRoot = options.projectRoot;
480
529
  this.cacheDir =
481
530
  options.cacheDir ?? join(options.projectRoot, ".dawg", "assets");
482
- this.maxCacheBytes = Math.max(
483
- 0,
484
- options.maxCacheBytes ?? SCORE_LIMITS.maxSampleCacheBytes,
531
+ this.maxCacheBytes = Math.max(0, options.maxCacheBytes ?? assetsCacheMax());
532
+ this.maxMemoryBytes = Math.min(
533
+ this.maxCacheBytes,
534
+ DEFAULT_MEMORY_CACHE_BYTES,
485
535
  );
486
536
  this.ffmpeg =
487
537
  options.ffmpeg === undefined
488
538
  ? (Bun.which("ffmpeg") ?? null)
489
539
  : options.ffmpeg;
490
540
  this.runFfmpeg = options.runFfmpeg;
541
+ this.packStore = options.packs;
542
+ }
543
+
544
+ private packStore: PackStore | undefined;
545
+
546
+ private get packs(): PackStore {
547
+ this.packStore ??= new PackStore();
548
+ return this.packStore;
491
549
  }
492
550
 
493
551
  /** Decode/cache counters, for tests and diagnostics. */
@@ -499,16 +557,137 @@ export class SampleLibrary implements SampleSource {
499
557
  return { ...this.stats };
500
558
  }
501
559
 
560
+ /** Decoded asset files on disk and the cap. */
561
+ public async cacheStatus(): Promise<CacheUsage & { max: number }> {
562
+ return {
563
+ ...(await cacheUsage(this.cacheDir, ASSET_FILE)),
564
+ max: this.maxCacheBytes,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Prunes decoded assets down to `maxBytes` (default the cap), keeping the
570
+ * ones the last loaded score uses.
571
+ */
572
+ public pruneCache(maxBytes = this.maxCacheBytes): Promise<PruneResult> {
573
+ const inUse = this.inUse;
574
+ return pruneLru(this.cacheDir, ASSET_FILE, maxBytes, (name) =>
575
+ inUse.has(name.slice(0, 64)),
576
+ );
577
+ }
578
+
502
579
  public async load(score: TrackScore): Promise<SampleBank> {
503
580
  const voices = new Map<string, DecodedSample>();
504
581
  const problems: SampleProblem[] = [];
505
582
  let dirs: ReadonlyMap<string, string> | undefined;
583
+ // The score's pins are this project's working set: neither cache evicts
584
+ // them while it is open.
585
+ this.inUse = new Set();
586
+ const urls: string[] = [];
587
+ for (const track of score.tracks) {
588
+ if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
589
+ for (const ref of Object.values(track.sampler.voices)) {
590
+ if (ref.sha256) this.inUse.add(ref.sha256);
591
+ if (ref.url) urls.push(ref.url);
592
+ }
593
+ }
594
+ for (const track of score.tracks) {
595
+ const ir = reverbIrSample(track);
596
+ if (ir?.sha256) this.inUse.add(ir.sha256);
597
+ if (ir?.url) urls.push(ir.url);
598
+ }
599
+ for (const track of score.tracks) {
600
+ const table = packWavetable(track);
601
+ if (table?.sha256) this.inUse.add(table.sha256);
602
+ if (table?.url) urls.push(table.url);
603
+ }
604
+ if (urls.length) this.packs.protect(urls);
605
+ const wavetables = new Map<string, Wavetable>();
606
+ for (const track of score.tracks) {
607
+ const ref = packWavetable(track);
608
+ if (!ref) continue;
609
+ try {
610
+ const loaded = await this.loadWavetable(ref);
611
+ wavetables.set(track.id, loaded.table);
612
+ if (loaded.warning)
613
+ problems.push({
614
+ trackId: track.id,
615
+ voice: "wavetable",
616
+ src: ref.src,
617
+ level: "warning",
618
+ message: `wavetable · ${ref.src} · ${loaded.warning}`,
619
+ });
620
+ } catch (error) {
621
+ problems.push({
622
+ trackId: track.id,
623
+ voice: "wavetable",
624
+ src: ref.src,
625
+ level: "error",
626
+ message: `wavetable · ${ref.src} · ${error instanceof Error ? error.message : String(error)} · /wt pick another table`,
627
+ });
628
+ }
629
+ }
630
+ for (const track of score.tracks) {
631
+ const ref = localWavetable(track);
632
+ if (!ref) continue;
633
+ try {
634
+ const loaded = await this.loadLocalWavetable(ref.src);
635
+ wavetables.set(track.id, loaded);
636
+ if (ref.sha256 && ref.sha256 !== loaded.id)
637
+ problems.push({
638
+ trackId: track.id,
639
+ voice: "wavetable",
640
+ src: ref.src,
641
+ level: "warning",
642
+ message: `wavetable · ${ref.src} changed since it was picked (sha256 ${loaded.id.slice(0, 12)}… ≠ ${ref.sha256.slice(0, 12)}…) · playing the file on disk; re-pick it with /wt to pin it`,
643
+ });
644
+ } catch (error) {
645
+ problems.push({
646
+ trackId: track.id,
647
+ voice: "wavetable",
648
+ src: ref.src,
649
+ level: "error",
650
+ message:
651
+ error instanceof WorkspaceError &&
652
+ /no such file/.test(error.message)
653
+ ? `wavetable · ${ref.src} · file is missing · remake it with make_wavetable or /wt another table`
654
+ : `wavetable · ${ref.src} · ${error instanceof Error ? error.message : String(error)} · /wt pick another table`,
655
+ });
656
+ }
657
+ }
506
658
  for (const track of score.tracks) {
507
659
  if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
508
660
  const names = Object.keys(track.sampler.voices).sort();
509
661
  for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices)) {
510
662
  const ref = track.sampler.voices[voice]!;
511
663
  let src = ref.src;
664
+ if (src.startsWith(PACK_PREFIX)) {
665
+ try {
666
+ const loaded = await this.loadPack(ref);
667
+ this.inUse.add(loaded.sample.sha256);
668
+ voices.set(sampleKey(track.id, voice), loaded.sample);
669
+ if (loaded.warning)
670
+ problems.push({
671
+ trackId: track.id,
672
+ voice,
673
+ src,
674
+ level: "warning",
675
+ message: `${voice} · ${src} · ${loaded.warning}`,
676
+ });
677
+ } catch (error) {
678
+ problems.push({
679
+ trackId: track.id,
680
+ voice,
681
+ src,
682
+ level: "error",
683
+ message:
684
+ error instanceof PackError
685
+ ? `${voice} · ${src} · ${error.message}`
686
+ : describeFailure(voice, src, error, this.ffmpeg),
687
+ });
688
+ }
689
+ continue;
690
+ }
512
691
  if (!src.startsWith("tracks/")) {
513
692
  dirs ??= trackDirectories(score);
514
693
  src = `tracks/${dirs.get(track.id) ?? track.id}/${src.replace(/^\.\//, "")}`;
@@ -523,6 +702,7 @@ export class SampleLibrary implements SampleSource {
523
702
  });
524
703
  try {
525
704
  const loaded = await this.loadFile(src);
705
+ this.inUse.add(loaded.sha256);
526
706
  if (ref.sha256 && ref.sha256 !== loaded.sha256)
527
707
  report(
528
708
  "warning",
@@ -534,7 +714,168 @@ export class SampleLibrary implements SampleSource {
534
714
  }
535
715
  }
536
716
  }
537
- return Object.freeze({ voices, problems: Object.freeze(problems) });
717
+ const irs = new Map<string, DecodedSample>();
718
+ for (const track of score.tracks) {
719
+ const ref = reverbIrSample(track);
720
+ if (!ref) continue;
721
+ const report = (level: SampleProblem["level"], message: string) =>
722
+ problems.push({
723
+ trackId: track.id,
724
+ voice: "ir",
725
+ src: ref.src,
726
+ level,
727
+ message,
728
+ });
729
+ try {
730
+ if (ref.src.startsWith(PACK_PREFIX)) {
731
+ const loaded = await this.loadPack(ref);
732
+ this.inUse.add(loaded.sample.sha256);
733
+ irs.set(track.id, loaded.sample);
734
+ if (loaded.warning)
735
+ report("warning", `ir · ${ref.src} · ${loaded.warning}`);
736
+ } else {
737
+ dirs ??= trackDirectories(score);
738
+ const src = ref.src.startsWith("tracks/")
739
+ ? ref.src
740
+ : `tracks/${dirs.get(track.id) ?? track.id}/${ref.src.replace(/^\.\//, "")}`;
741
+ const loaded = await this.loadFile(src);
742
+ this.inUse.add(loaded.sha256);
743
+ irs.set(track.id, loaded);
744
+ }
745
+ } catch (error) {
746
+ report(
747
+ "error",
748
+ error instanceof PackError
749
+ ? `ir · ${ref.src} · ${error.message} · fx reverb ir hall uses a built-in`
750
+ : describeFailure("ir", ref.src, error, this.ffmpeg),
751
+ );
752
+ }
753
+ }
754
+ return Object.freeze({
755
+ voices,
756
+ problems: Object.freeze(problems),
757
+ ...(wavetables.size ? { wavetables } : {}),
758
+ ...(irs.size ? { irs } : {}),
759
+ });
760
+ }
761
+
762
+ /**
763
+ * A pack wavetable: the raw WAV from the pack file cache (or the network,
764
+ * checked against the pin), sliced into frames. Tables stay WAV on disk
765
+ * because the frame layout lives in the file's `clm ` chunk.
766
+ */
767
+ private async loadWavetable(
768
+ ref: SampleRef,
769
+ ): Promise<{ table: Wavetable; warning?: string }> {
770
+ const memo = ref.sha256 ? this.tables.get(ref.sha256) : undefined;
771
+ if (memo) return { table: memo };
772
+ let url = ref.url;
773
+ let warning: string | undefined;
774
+ if (!url) {
775
+ url = (await this.packs.resolve(ref.src)).url;
776
+ if (!ref.sha256)
777
+ warning =
778
+ "not pinned · re-pick it with /wt so renders stay reproducible";
779
+ }
780
+ const file = await this.packs.fetchFile(url, ref.sha256);
781
+ let table = this.tables.get(file.sha256);
782
+ if (!table) {
783
+ table = wavetableFromWav(ref.src, file.sha256, file.bytes);
784
+ this.tables.set(file.sha256, table);
785
+ if (this.tables.size > MAX_MEMORY_WAVETABLES)
786
+ this.tables.delete(this.tables.keys().next().value!);
787
+ }
788
+ return { table, ...(warning ? { warning } : {}) };
789
+ }
790
+
791
+ /**
792
+ * A project wavetable WAV, read within the project root (symlinks that
793
+ * leave it are refused) and memoized by content hash.
794
+ */
795
+ private async loadLocalWavetable(src: string): Promise<Wavetable> {
796
+ const resolved = await resolveReadPath(
797
+ { root: this.projectRoot, trackSlug: "" },
798
+ src,
799
+ "wavetable",
800
+ );
801
+ const info = await stat(resolved.real);
802
+ if (!info.isFile()) throw new SampleDecodeError("not a regular file");
803
+ if (info.size > SCORE_LIMITS.maxSampleFileBytes)
804
+ throw new SampleLimitError(
805
+ `${formatMiB(info.size)} is over the ${formatMiB(SCORE_LIMITS.maxSampleFileBytes)} file limit`,
806
+ );
807
+ const bytes = new Uint8Array(await readFile(resolved.real));
808
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
809
+ let table = this.tables.get(sha256);
810
+ if (!table) {
811
+ table = wavetableFromWav(src, sha256, bytes);
812
+ this.tables.set(sha256, table);
813
+ if (this.tables.size > MAX_MEMORY_WAVETABLES)
814
+ this.tables.delete(this.tables.keys().next().value!);
815
+ }
816
+ return table;
817
+ }
818
+
819
+ /**
820
+ * A `pack:` voice. With a pinned sha256 the decoded asset cache answers
821
+ * without the network; otherwise the file comes from the pack store
822
+ * (its file cache, then a download) and is checked against the pin.
823
+ */
824
+ private async loadPack(
825
+ ref: SampleRef,
826
+ ): Promise<{ sample: DecodedSample; warning?: string }> {
827
+ if (ref.sha256) {
828
+ const hit = this.fromMemory(ref.sha256);
829
+ if (hit) return { sample: hit };
830
+ const pcm = await this.fromDisk(ref.sha256);
831
+ if (pcm) {
832
+ this.stats.diskHits += 1;
833
+ return { sample: this.admit(ref.sha256, pcm) };
834
+ }
835
+ }
836
+ let url = ref.url;
837
+ let warning: string | undefined;
838
+ if (!url) {
839
+ url = (await this.packs.resolve(ref.src)).url;
840
+ if (!ref.sha256)
841
+ warning =
842
+ "not pinned · re-pick it with /kit, /pack or use_sound so renders stay reproducible";
843
+ }
844
+ const file = await this.packs.fetchFile(url, ref.sha256);
845
+ if (file.bytes.byteLength > SCORE_LIMITS.maxSampleFileBytes)
846
+ throw new SampleLimitError(
847
+ `${formatMiB(file.bytes.byteLength)} is over the ${formatMiB(SCORE_LIMITS.maxSampleFileBytes)} sample file limit`,
848
+ );
849
+ const hit = this.fromMemory(file.sha256);
850
+ if (hit) return { sample: hit, ...(warning ? { warning } : {}) };
851
+ let pcm = await this.fromDisk(file.sha256);
852
+ if (pcm) this.stats.diskHits += 1;
853
+ else {
854
+ pcm = await this.decode(file.bytes, file.path);
855
+ this.stats.decodes += 1;
856
+ await this.toDisk(file.sha256, pcm);
857
+ }
858
+ return {
859
+ sample: this.admit(file.sha256, pcm),
860
+ ...(warning ? { warning } : {}),
861
+ };
862
+ }
863
+
864
+ private admit(sha256: string, pcm: PcmData): DecodedSample {
865
+ if (pcm.frames / pcm.sampleRate > SCORE_LIMITS.maxSampleSeconds)
866
+ throw new SampleLimitError(
867
+ `${Math.round(pcm.frames / pcm.sampleRate)} s is over the ${SCORE_LIMITS.maxSampleSeconds / 60} minute sample limit`,
868
+ );
869
+ if (pcm.frames === 0) throw new SampleDecodeError("it contains no audio");
870
+ const sample: DecodedSample = Object.freeze({
871
+ sha256,
872
+ sampleRate: pcm.sampleRate,
873
+ channels: pcm.channels,
874
+ frames: pcm.frames,
875
+ mono: monoMix(pcm),
876
+ });
877
+ this.remember(sample);
878
+ return sample;
538
879
  }
539
880
 
540
881
  private async loadFile(src: string): Promise<DecodedSample> {
@@ -606,7 +947,7 @@ export class SampleLibrary implements SampleSource {
606
947
  this.memory.set(sample.sha256, { sample, bytes });
607
948
  this.memoryBytes += bytes;
608
949
  for (const [sha, entry] of this.memory) {
609
- if (this.memoryBytes <= this.maxCacheBytes) break;
950
+ if (this.memoryBytes <= this.maxMemoryBytes) break;
610
951
  if (sha === sample.sha256) continue;
611
952
  this.memory.delete(sha);
612
953
  this.memoryBytes -= entry.bytes;
@@ -635,7 +976,8 @@ export class SampleLibrary implements SampleSource {
635
976
  const temporary = `${path}.${process.pid}.tmp`;
636
977
  await writeFile(temporary, encodeCachedPcm(pcm));
637
978
  await rename(temporary, path);
638
- await pruneAssetCache(this.cacheDir, this.maxCacheBytes, sha256);
979
+ const inUse = new Set(this.inUse).add(sha256);
980
+ await pruneAssetCache(this.cacheDir, this.maxCacheBytes, inUse);
639
981
  } catch {
640
982
  /* the cache is an optimisation; a read-only project still renders */
641
983
  }