@hraness/dawg 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/src/audio/samples.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
* `
|
|
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
|
-
/**
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|