@hraness/dawg 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * Sample loading for sampler tracks (score v2). Every voice's `src` is
3
+ * resolved inside the project (lexically, then through `realpath`, never into
4
+ * `.dawg/`), hashed, decoded and handed to the renderer as a `SampleBank`.
5
+ *
6
+ * - WAV (PCM 16/24/32-bit int, 32-bit float) and AIFF/AIFC (8/16/24/32-bit
7
+ * int, `sowt`, `fl32`) decode natively in TypeScript.
8
+ * - Anything else (mp3, flac, ogg, m4a, …) decodes through `ffmpeg` when it is
9
+ * on PATH; dawg never installs it.
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.
14
+ *
15
+ * Loading never throws for project problems: a voice that cannot load is
16
+ * reported as a `<what> · <why> · <next step>` problem and renders silent.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import {
20
+ mkdir,
21
+ readFile,
22
+ rename,
23
+ stat,
24
+ utimes,
25
+ writeFile,
26
+ } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ import {
29
+ PACK_PREFIX,
30
+ SCORE_LIMITS,
31
+ isSamplerInstrument,
32
+ isLocalTableSrc,
33
+ isWavetableInstrument,
34
+ type SampleRef,
35
+ type TrackScore,
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";
47
+ import { trackDirectories } from "../../core/sdk/print.ts";
48
+ import { WavFormatError, parseWav } from "../media/vendor/wav.ts";
49
+ import { WorkspaceError, resolveReadPath } from "../agent/workspace.ts";
50
+
51
+ /** One decoded sample: source rate, mono mixdown for the renderer. */
52
+ export type DecodedSample = Readonly<{
53
+ sha256: string;
54
+ sampleRate: number;
55
+ /** Channels in the source file (the renderer plays a mono mixdown). */
56
+ channels: number;
57
+ frames: number;
58
+ mono: Float32Array;
59
+ }>;
60
+
61
+ export type SampleProblem = Readonly<{
62
+ trackId: string;
63
+ voice: string;
64
+ src: string;
65
+ level: "error" | "warning";
66
+ /** `<what> · <why> · <next step>`. */
67
+ message: string;
68
+ }>;
69
+
70
+ /** Decoded voices keyed by `sampleKey(trackId, voice)`, plus load problems. */
71
+ export type SampleBank = Readonly<{
72
+ voices: ReadonlyMap<string, DecodedSample>;
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>;
78
+ }>;
79
+
80
+ export const EMPTY_SAMPLE_BANK: SampleBank = Object.freeze({
81
+ voices: new Map<string, DecodedSample>(),
82
+ problems: Object.freeze([]),
83
+ });
84
+
85
+ /**
86
+ * True when the score needs the sample library: a sampler with voices, or a
87
+ * wavetable track playing a pack table.
88
+ */
89
+ export function hasSamplerTracks(score: TrackScore): boolean {
90
+ return score.tracks.some(
91
+ (track) =>
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,
98
+ );
99
+ }
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
+
130
+ export function sampleKey(trackId: string, voice: string): string {
131
+ return `${trackId}\u0000${voice}`;
132
+ }
133
+
134
+ /** Anything that resolves sample voices for a score (the engine's hook). */
135
+ export interface SampleSource {
136
+ load(score: TrackScore): Promise<SampleBank>;
137
+ }
138
+
139
+ /** Interleaved float PCM at the file's own rate. */
140
+ export type PcmData = Readonly<{
141
+ sampleRate: number;
142
+ channels: number;
143
+ frames: number;
144
+ data: Float32Array;
145
+ }>;
146
+
147
+ export class SampleDecodeError extends Error {
148
+ constructor(message: string) {
149
+ super(message);
150
+ this.name = "SampleDecodeError";
151
+ }
152
+ }
153
+
154
+ const MIN_RATE = 8_000;
155
+ const MAX_RATE = 192_000;
156
+ const MAX_CHANNELS = 8;
157
+ /** ffmpeg output: fixed so the cache does not depend on probing. */
158
+ const FFMPEG_RATE = 48_000;
159
+ const FFMPEG_CHANNELS = 2;
160
+ const FFMPEG_TIMEOUT_MS = 60_000;
161
+ const CACHE_MAGIC = 0x4d435044; // "DPCM" little-endian
162
+ const CACHE_VERSION = 1;
163
+ const CACHE_HEADER_BYTES = 16;
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Native decoders
167
+
168
+ function ascii(bytes: Uint8Array, offset: number, count: number): string {
169
+ return String.fromCharCode(...bytes.subarray(offset, offset + count));
170
+ }
171
+
172
+ /** The container a file's first bytes announce, if dawg decodes it natively. */
173
+ export function nativeFormat(bytes: Uint8Array): "wav" | "aiff" | undefined {
174
+ if (bytes.byteLength < 12) return undefined;
175
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE")
176
+ return "wav";
177
+ const form = ascii(bytes, 8, 4);
178
+ if (ascii(bytes, 0, 4) === "FORM" && (form === "AIFF" || form === "AIFC"))
179
+ return "aiff";
180
+ return undefined;
181
+ }
182
+
183
+ /** WAV through the vendored media parser (PCM 16/24/32, float32). */
184
+ export function decodeWav(bytes: Uint8Array): PcmData {
185
+ let parsed;
186
+ try {
187
+ parsed = parseWav(bytes, {
188
+ maximumBytes: SCORE_LIMITS.maxSampleFileBytes,
189
+ maximumDurationSeconds: SCORE_LIMITS.maxSampleSeconds,
190
+ maximumChannels: MAX_CHANNELS,
191
+ });
192
+ } catch (error) {
193
+ if (error instanceof WavFormatError && error.code === "too_long")
194
+ throw new SampleLimitError(
195
+ `longer than the ${SCORE_LIMITS.maxSampleSeconds / 60} minute sample limit`,
196
+ );
197
+ if (error instanceof WavFormatError)
198
+ throw new SampleDecodeError(error.message.replace(/\.$/, ""));
199
+ throw error;
200
+ }
201
+ const { sampleRate, channels, sampleCount: frames } = parsed;
202
+ const data = new Float32Array(frames * channels);
203
+ for (let frame = 0; frame < frames; frame += 1)
204
+ for (let channel = 0; channel < channels; channel += 1)
205
+ data[frame * channels + channel] = parsed.sample(frame, channel);
206
+ return { sampleRate, channels, frames, data };
207
+ }
208
+
209
+ /** IEEE 754 80-bit extended (AIFF sample rate). */
210
+ function extended80(view: DataView, offset: number): number {
211
+ const exponent = view.getUint16(offset) & 0x7fff;
212
+ const hi = view.getUint32(offset + 2);
213
+ const lo = view.getUint32(offset + 6);
214
+ if (exponent === 0 && hi === 0 && lo === 0) return 0;
215
+ return (hi * 2 ** 32 + lo) * 2 ** (exponent - 16383 - 63);
216
+ }
217
+
218
+ /** AIFF and AIFC (`NONE`, `sowt`, `fl32`/`FL32`). */
219
+ export function decodeAiff(bytes: Uint8Array): PcmData {
220
+ if (nativeFormat(bytes) !== "aiff")
221
+ throw new SampleDecodeError("file is not AIFF");
222
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
223
+ const aifc = ascii(bytes, 8, 4) === "AIFC";
224
+ let channels = 0;
225
+ let frames = 0;
226
+ let bits = 0;
227
+ let sampleRate = 0;
228
+ let compression = "NONE";
229
+ let dataStart = -1;
230
+ let offset = 12;
231
+ while (offset + 8 <= bytes.byteLength) {
232
+ const id = ascii(bytes, offset, 4);
233
+ const size = view.getUint32(offset + 4);
234
+ const start = offset + 8;
235
+ if (start + size > bytes.byteLength)
236
+ throw new SampleDecodeError("AIFF contains a truncated chunk");
237
+ if (id === "COMM") {
238
+ if (size < 18) throw new SampleDecodeError("AIFF COMM chunk is short");
239
+ channels = view.getUint16(start);
240
+ frames = view.getUint32(start + 2);
241
+ bits = view.getUint16(start + 6);
242
+ sampleRate = extended80(view, start + 8);
243
+ if (aifc && size >= 22) compression = ascii(bytes, start + 18, 4);
244
+ } else if (id === "SSND") {
245
+ const dataOffset = view.getUint32(start);
246
+ dataStart = start + 8 + dataOffset;
247
+ }
248
+ offset = start + size + (size & 1);
249
+ }
250
+ if (dataStart < 0 || channels === 0)
251
+ throw new SampleDecodeError("AIFF is missing COMM or SSND");
252
+ const float = compression === "fl32" || compression === "FL32";
253
+ const little = compression === "sowt";
254
+ if (!float && !little && compression !== "NONE")
255
+ throw new SampleDecodeError(`AIFC compression ${compression} is not PCM`);
256
+ if (float) bits = 32;
257
+ if (![8, 16, 24, 32].includes(bits))
258
+ throw new SampleDecodeError(`AIFF ${bits}-bit samples are unsupported`);
259
+ if (little && bits !== 16)
260
+ throw new SampleDecodeError("AIFC sowt must be 16-bit");
261
+ checkShape(sampleRate, channels, frames);
262
+ const width = bits / 8;
263
+ if (dataStart + frames * channels * width > bytes.byteLength)
264
+ throw new SampleDecodeError("AIFF sound data is truncated");
265
+ const data = new Float32Array(frames * channels);
266
+ for (let index = 0; index < frames * channels; index += 1) {
267
+ const at = dataStart + index * width;
268
+ let value: number;
269
+ if (float) value = Math.max(-1, Math.min(1, view.getFloat32(at)));
270
+ else if (bits === 8) value = view.getInt8(at) / 128;
271
+ else if (bits === 16) value = view.getInt16(at, little) / 32_768;
272
+ else if (bits === 24) {
273
+ let raw = (bytes[at]! << 16) | (bytes[at + 1]! << 8) | bytes[at + 2]!;
274
+ if (raw & 0x800000) raw |= ~0xffffff;
275
+ value = raw / 8_388_608;
276
+ } else value = view.getInt32(at) / 2_147_483_648;
277
+ data[index] = value;
278
+ }
279
+ return { sampleRate, channels, frames, data };
280
+ }
281
+
282
+ function checkShape(sampleRate: number, channels: number, frames: number) {
283
+ if (
284
+ !Number.isFinite(sampleRate) ||
285
+ sampleRate < MIN_RATE ||
286
+ sampleRate > MAX_RATE
287
+ )
288
+ throw new SampleDecodeError(
289
+ `sample rate ${Math.round(sampleRate)} Hz is outside ${MIN_RATE}–${MAX_RATE} Hz`,
290
+ );
291
+ if (channels < 1 || channels > MAX_CHANNELS)
292
+ throw new SampleDecodeError(`${channels} channels is unsupported`);
293
+ if (frames / sampleRate > SCORE_LIMITS.maxSampleSeconds)
294
+ throw new SampleDecodeError(
295
+ `longer than ${SCORE_LIMITS.maxSampleSeconds / 60} minutes`,
296
+ );
297
+ }
298
+
299
+ /** Average the channels; the renderer plays sampler voices in mono before pan. */
300
+ export function monoMix(pcm: PcmData): Float32Array {
301
+ if (pcm.channels === 1) return pcm.data;
302
+ const mono = new Float32Array(pcm.frames);
303
+ for (let frame = 0; frame < pcm.frames; frame += 1) {
304
+ let sum = 0;
305
+ for (let channel = 0; channel < pcm.channels; channel += 1)
306
+ sum += pcm.data[frame * pcm.channels + channel]!;
307
+ mono[frame] = sum / pcm.channels;
308
+ }
309
+ return mono;
310
+ }
311
+
312
+ /**
313
+ * Linear-interpolated resampling of a mono buffer. The renderer resamples on
314
+ * the fly (rate = speed × source/engine rate); this is the same arithmetic
315
+ * for callers that want a buffer at the engine rate.
316
+ */
317
+ export function resampleLinear(
318
+ input: Float32Array,
319
+ fromRate: number,
320
+ toRate: number,
321
+ ): Float32Array {
322
+ if (fromRate === toRate || input.length === 0) return input.slice();
323
+ const length = Math.max(1, Math.round((input.length * toRate) / fromRate));
324
+ const out = new Float32Array(length);
325
+ const step = fromRate / toRate;
326
+ for (let index = 0; index < length; index += 1) {
327
+ const position = index * step;
328
+ const base = Math.floor(position);
329
+ const next = Math.min(input.length - 1, base + 1);
330
+ const fraction = position - base;
331
+ const a = input[Math.min(input.length - 1, base)]!;
332
+ out[index] = a + (input[next]! - a) * fraction;
333
+ }
334
+ return out;
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // ffmpeg
339
+
340
+ export type FfmpegRunner = (
341
+ args: readonly string[],
342
+ options: Readonly<{ timeoutMs: number; maxBytes: number }>,
343
+ ) => Promise<Readonly<{ code: number; stdout: Uint8Array; stderr: string }>>;
344
+
345
+ const bunFfmpeg: FfmpegRunner = async (args, options) => {
346
+ const proc = Bun.spawn([...args], {
347
+ stdin: "ignore",
348
+ stdout: "pipe",
349
+ stderr: "pipe",
350
+ });
351
+ const timer = setTimeout(() => proc.kill(), options.timeoutMs);
352
+ try {
353
+ const chunks: Uint8Array[] = [];
354
+ let total = 0;
355
+ for await (const chunk of proc.stdout) {
356
+ total += chunk.byteLength;
357
+ if (total > options.maxBytes) {
358
+ proc.kill();
359
+ throw new SampleDecodeError(
360
+ `decoded audio is longer than ${SCORE_LIMITS.maxSampleSeconds / 60} minutes`,
361
+ );
362
+ }
363
+ chunks.push(chunk);
364
+ }
365
+ const stderr = await new Response(proc.stderr).text();
366
+ const code = await proc.exited;
367
+ const stdout = new Uint8Array(total);
368
+ let at = 0;
369
+ for (const chunk of chunks) {
370
+ stdout.set(chunk, at);
371
+ at += chunk.byteLength;
372
+ }
373
+ return { code, stdout, stderr };
374
+ } finally {
375
+ clearTimeout(timer);
376
+ }
377
+ };
378
+
379
+ /** Decode any ffmpeg-readable file to 48 kHz stereo float. */
380
+ export async function decodeWithFfmpeg(
381
+ ffmpeg: string,
382
+ path: string,
383
+ run: FfmpegRunner = bunFfmpeg,
384
+ ): Promise<PcmData> {
385
+ const maxBytes =
386
+ SCORE_LIMITS.maxSampleSeconds * FFMPEG_RATE * FFMPEG_CHANNELS * 4 + 4096;
387
+ const result = await run(
388
+ [
389
+ ffmpeg,
390
+ "-hide_banner",
391
+ "-nostdin",
392
+ "-loglevel",
393
+ "error",
394
+ "-i",
395
+ path,
396
+ "-vn",
397
+ "-t",
398
+ String(SCORE_LIMITS.maxSampleSeconds),
399
+ "-f",
400
+ "f32le",
401
+ "-acodec",
402
+ "pcm_f32le",
403
+ "-ac",
404
+ String(FFMPEG_CHANNELS),
405
+ "-ar",
406
+ String(FFMPEG_RATE),
407
+ "-",
408
+ ],
409
+ { timeoutMs: FFMPEG_TIMEOUT_MS, maxBytes },
410
+ );
411
+ if (result.code !== 0) {
412
+ const line = result.stderr.trim().split("\n").pop()?.slice(0, 160);
413
+ throw new SampleDecodeError(
414
+ `ffmpeg could not decode it${line ? ` (${line})` : ""}`,
415
+ );
416
+ }
417
+ const frames = Math.floor(result.stdout.byteLength / (4 * FFMPEG_CHANNELS));
418
+ if (frames === 0) throw new SampleDecodeError("ffmpeg produced no audio");
419
+ const data = new Float32Array(frames * FFMPEG_CHANNELS);
420
+ const view = new DataView(
421
+ result.stdout.buffer,
422
+ result.stdout.byteOffset,
423
+ frames * FFMPEG_CHANNELS * 4,
424
+ );
425
+ for (let index = 0; index < data.length; index += 1)
426
+ data[index] = Math.max(-1, Math.min(1, view.getFloat32(index * 4, true)));
427
+ return { sampleRate: FFMPEG_RATE, channels: FFMPEG_CHANNELS, frames, data };
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Content-addressed PCM cache
432
+
433
+ export function encodeCachedPcm(pcm: PcmData): Uint8Array {
434
+ const out = new Uint8Array(CACHE_HEADER_BYTES + pcm.data.length * 4);
435
+ const view = new DataView(out.buffer);
436
+ view.setUint32(0, CACHE_MAGIC, true);
437
+ view.setUint32(4, CACHE_VERSION, true);
438
+ view.setUint32(8, pcm.sampleRate, true);
439
+ view.setUint32(12, pcm.channels, true);
440
+ for (let index = 0; index < pcm.data.length; index += 1)
441
+ view.setFloat32(CACHE_HEADER_BYTES + index * 4, pcm.data[index]!, true);
442
+ return out;
443
+ }
444
+
445
+ export function decodeCachedPcm(bytes: Uint8Array): PcmData | undefined {
446
+ if (bytes.byteLength < CACHE_HEADER_BYTES) return undefined;
447
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
448
+ if (
449
+ view.getUint32(0, true) !== CACHE_MAGIC ||
450
+ view.getUint32(4, true) !== CACHE_VERSION
451
+ )
452
+ return undefined;
453
+ const sampleRate = view.getUint32(8, true);
454
+ const channels = view.getUint32(12, true);
455
+ const body = bytes.byteLength - CACHE_HEADER_BYTES;
456
+ if (channels < 1 || channels > MAX_CHANNELS || body % (4 * channels) !== 0)
457
+ return undefined;
458
+ const data = new Float32Array(body / 4);
459
+ for (let index = 0; index < data.length; index += 1)
460
+ data[index] = view.getFloat32(CACHE_HEADER_BYTES + index * 4, true);
461
+ return { sampleRate, channels, frames: data.length / channels, data };
462
+ }
463
+
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
+ */
471
+ export async function pruneAssetCache(
472
+ dir: string,
473
+ maxBytes: number,
474
+ keep?: string | ReadonlySet<string>,
475
+ ): Promise<number> {
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;
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Library
487
+
488
+ export type SampleLibraryOptions = Readonly<{
489
+ /** Absolute project root; sample paths must stay inside it. */
490
+ projectRoot: string;
491
+ /** Default `<projectRoot>/.dawg/assets`. */
492
+ cacheDir?: string;
493
+ /**
494
+ * Disk cache budget; default `assetsCacheMax()` (`DAWG_ASSETS_CACHE_MAX`,
495
+ * 1 GiB). Memory uses the smaller of this and 512 MiB.
496
+ */
497
+ maxCacheBytes?: number;
498
+ /** ffmpeg binary, `null` for none; default `Bun.which("ffmpeg")`. */
499
+ ffmpeg?: string | null;
500
+ runFfmpeg?: FfmpegRunner;
501
+ /** Resolves `pack:` voices; default a `PackStore` on the user cache. */
502
+ packs?: PackStore;
503
+ }>;
504
+
505
+ type MemoryEntry = { sample: DecodedSample; bytes: number };
506
+
507
+ /**
508
+ * Loads a score's sample voices. Decoded samples stay in an in-memory LRU
509
+ * keyed by content hash, so re-renders after an edit cost a `stat` per voice.
510
+ */
511
+ export class SampleLibrary implements SampleSource {
512
+ public readonly projectRoot: string;
513
+ public readonly cacheDir: string;
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>();
518
+ private readonly ffmpeg: string | null;
519
+ private readonly runFfmpeg: FfmpegRunner | undefined;
520
+ private readonly memory = new Map<string, MemoryEntry>();
521
+ private memoryBytes = 0;
522
+ /** `real:size:mtime` → sha256, so unchanged files are not re-hashed. */
523
+ private readonly hashes = new Map<string, string>();
524
+ private readonly tables = new Map<string, Wavetable>();
525
+ private stats = { decodes: 0, diskHits: 0, memoryHits: 0 };
526
+
527
+ public constructor(options: SampleLibraryOptions) {
528
+ this.projectRoot = options.projectRoot;
529
+ this.cacheDir =
530
+ options.cacheDir ?? join(options.projectRoot, ".dawg", "assets");
531
+ this.maxCacheBytes = Math.max(0, options.maxCacheBytes ?? assetsCacheMax());
532
+ this.maxMemoryBytes = Math.min(
533
+ this.maxCacheBytes,
534
+ DEFAULT_MEMORY_CACHE_BYTES,
535
+ );
536
+ this.ffmpeg =
537
+ options.ffmpeg === undefined
538
+ ? (Bun.which("ffmpeg") ?? null)
539
+ : options.ffmpeg;
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;
549
+ }
550
+
551
+ /** Decode/cache counters, for tests and diagnostics. */
552
+ public get counters(): Readonly<{
553
+ decodes: number;
554
+ diskHits: number;
555
+ memoryHits: number;
556
+ }> {
557
+ return { ...this.stats };
558
+ }
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
+
579
+ public async load(score: TrackScore): Promise<SampleBank> {
580
+ const voices = new Map<string, DecodedSample>();
581
+ const problems: SampleProblem[] = [];
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
+ }
658
+ for (const track of score.tracks) {
659
+ if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
660
+ const names = Object.keys(track.sampler.voices).sort();
661
+ for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices)) {
662
+ const ref = track.sampler.voices[voice]!;
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
+ }
691
+ if (!src.startsWith("tracks/")) {
692
+ dirs ??= trackDirectories(score);
693
+ src = `tracks/${dirs.get(track.id) ?? track.id}/${src.replace(/^\.\//, "")}`;
694
+ }
695
+ const report = (level: SampleProblem["level"], message: string) =>
696
+ problems.push({
697
+ trackId: track.id,
698
+ voice,
699
+ src: ref.src,
700
+ level,
701
+ message,
702
+ });
703
+ try {
704
+ const loaded = await this.loadFile(src);
705
+ this.inUse.add(loaded.sha256);
706
+ if (ref.sha256 && ref.sha256 !== loaded.sha256)
707
+ report(
708
+ "warning",
709
+ `${voice} · ${src} changed since it was imported (sha256 ${loaded.sha256.slice(0, 12)}… ≠ ${ref.sha256.slice(0, 12)}…) · playing the file on disk; re-import or update sha256 in track.ts`,
710
+ );
711
+ voices.set(sampleKey(track.id, voice), loaded);
712
+ } catch (error) {
713
+ report("error", describeFailure(voice, src, error, this.ffmpeg));
714
+ }
715
+ }
716
+ }
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;
879
+ }
880
+
881
+ private async loadFile(src: string): Promise<DecodedSample> {
882
+ const resolved = await resolveReadPath(
883
+ { root: this.projectRoot, trackSlug: "" },
884
+ src,
885
+ "sample",
886
+ );
887
+ const info = await stat(resolved.real);
888
+ if (!info.isFile()) throw new SampleDecodeError("not a regular file");
889
+ if (info.size > SCORE_LIMITS.maxSampleFileBytes)
890
+ throw new SampleLimitError(
891
+ `${formatMiB(info.size)} is over the ${formatMiB(SCORE_LIMITS.maxSampleFileBytes)} sample file limit`,
892
+ );
893
+ const statKey = `${resolved.real}:${info.size}:${info.mtimeMs}`;
894
+ const knownSha = this.hashes.get(statKey);
895
+ if (knownSha) {
896
+ const hit = this.fromMemory(knownSha);
897
+ if (hit) return hit;
898
+ }
899
+ const bytes = new Uint8Array(await readFile(resolved.real));
900
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
901
+ this.hashes.set(statKey, sha256);
902
+ if (this.hashes.size > 4096) this.hashes.clear();
903
+ const hit = this.fromMemory(sha256);
904
+ if (hit) return hit;
905
+ let pcm = await this.fromDisk(sha256);
906
+ if (pcm) this.stats.diskHits += 1;
907
+ else {
908
+ pcm = await this.decode(bytes, resolved.real);
909
+ this.stats.decodes += 1;
910
+ await this.toDisk(sha256, pcm);
911
+ }
912
+ if (pcm.frames / pcm.sampleRate > SCORE_LIMITS.maxSampleSeconds)
913
+ throw new SampleLimitError(
914
+ `${Math.round(pcm.frames / pcm.sampleRate)} s is over the ${SCORE_LIMITS.maxSampleSeconds / 60} minute sample limit`,
915
+ );
916
+ if (pcm.frames === 0) throw new SampleDecodeError("it contains no audio");
917
+ const sample: DecodedSample = Object.freeze({
918
+ sha256,
919
+ sampleRate: pcm.sampleRate,
920
+ channels: pcm.channels,
921
+ frames: pcm.frames,
922
+ mono: monoMix(pcm),
923
+ });
924
+ this.remember(sample);
925
+ return sample;
926
+ }
927
+
928
+ private async decode(bytes: Uint8Array, path: string): Promise<PcmData> {
929
+ const format = nativeFormat(bytes);
930
+ if (format === "wav") return decodeWav(bytes);
931
+ if (format === "aiff") return decodeAiff(bytes);
932
+ if (!this.ffmpeg) throw new FfmpegMissingError();
933
+ return decodeWithFfmpeg(this.ffmpeg, path, this.runFfmpeg);
934
+ }
935
+
936
+ private fromMemory(sha256: string): DecodedSample | undefined {
937
+ const entry = this.memory.get(sha256);
938
+ if (!entry) return undefined;
939
+ this.memory.delete(sha256);
940
+ this.memory.set(sha256, entry);
941
+ this.stats.memoryHits += 1;
942
+ return entry.sample;
943
+ }
944
+
945
+ private remember(sample: DecodedSample): void {
946
+ const bytes = sample.mono.byteLength;
947
+ this.memory.set(sample.sha256, { sample, bytes });
948
+ this.memoryBytes += bytes;
949
+ for (const [sha, entry] of this.memory) {
950
+ if (this.memoryBytes <= this.maxMemoryBytes) break;
951
+ if (sha === sample.sha256) continue;
952
+ this.memory.delete(sha);
953
+ this.memoryBytes -= entry.bytes;
954
+ }
955
+ }
956
+
957
+ private async fromDisk(sha256: string): Promise<PcmData | undefined> {
958
+ const path = join(this.cacheDir, `${sha256}.pcm`);
959
+ try {
960
+ const pcm = decodeCachedPcm(new Uint8Array(await readFile(path)));
961
+ if (!pcm) return undefined;
962
+ const now = new Date();
963
+ await utimes(path, now, now).catch(() => undefined);
964
+ return pcm;
965
+ } catch {
966
+ return undefined;
967
+ }
968
+ }
969
+
970
+ private async toDisk(sha256: string, pcm: PcmData): Promise<void> {
971
+ const size = CACHE_HEADER_BYTES + pcm.data.length * 4;
972
+ if (size > this.maxCacheBytes) return;
973
+ try {
974
+ await mkdir(this.cacheDir, { recursive: true });
975
+ const path = join(this.cacheDir, `${sha256}.pcm`);
976
+ const temporary = `${path}.${process.pid}.tmp`;
977
+ await writeFile(temporary, encodeCachedPcm(pcm));
978
+ await rename(temporary, path);
979
+ const inUse = new Set(this.inUse).add(sha256);
980
+ await pruneAssetCache(this.cacheDir, this.maxCacheBytes, inUse);
981
+ } catch {
982
+ /* the cache is an optimisation; a read-only project still renders */
983
+ }
984
+ }
985
+ }
986
+
987
+ export class SampleLimitError extends Error {
988
+ constructor(message: string) {
989
+ super(message);
990
+ this.name = "SampleLimitError";
991
+ }
992
+ }
993
+
994
+ class FfmpegMissingError extends Error {
995
+ constructor() {
996
+ super("ffmpeg is not on PATH");
997
+ this.name = "FfmpegMissingError";
998
+ }
999
+ }
1000
+
1001
+ function formatMiB(bytes: number): string {
1002
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MiB`;
1003
+ }
1004
+
1005
+ function describeFailure(
1006
+ voice: string,
1007
+ src: string,
1008
+ error: unknown,
1009
+ ffmpeg: string | null,
1010
+ ): string {
1011
+ const what = `${voice} · ${src}`;
1012
+ if (error instanceof WorkspaceError) {
1013
+ if (/no such file/.test(error.message))
1014
+ return `${what} · file is missing · add it under the track's samples/ or fix src in track.ts`;
1015
+ return `${what} · ${error.message} · keep samples inside the project, e.g. tracks/<slug>/samples/`;
1016
+ }
1017
+ if (error instanceof FfmpegMissingError)
1018
+ return `${what} · not WAV/AIFF and ffmpeg is not on PATH · convert it to WAV, or install ffmpeg (e.g. brew install ffmpeg) and reload`;
1019
+ if (error instanceof SampleLimitError)
1020
+ return `${what} · ${error.message} · trim it or import a shorter section`;
1021
+ if (error instanceof SampleDecodeError)
1022
+ return `${what} · ${error.message} · ${ffmpeg ? "re-export it as 16/24-bit WAV" : "re-export it as 16/24-bit WAV, or install ffmpeg"}`;
1023
+ const message = error instanceof Error ? error.message : String(error);
1024
+ return `${what} · ${message.slice(0, 160)} · check the file and reload`;
1025
+ }