@hraness/dawg 0.2.0 → 0.3.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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
|
@@ -0,0 +1,683 @@
|
|
|
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
|
+
* (and in memory), least recently used first out, bounded by
|
|
12
|
+
* `SCORE_LIMITS.maxSampleCacheBytes`.
|
|
13
|
+
*
|
|
14
|
+
* Loading never throws for project problems: a voice that cannot load is
|
|
15
|
+
* reported as a `<what> · <why> · <next step>` problem and renders silent.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import {
|
|
19
|
+
mkdir,
|
|
20
|
+
readdir,
|
|
21
|
+
readFile,
|
|
22
|
+
rename,
|
|
23
|
+
stat,
|
|
24
|
+
unlink,
|
|
25
|
+
utimes,
|
|
26
|
+
writeFile,
|
|
27
|
+
} from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
SCORE_LIMITS,
|
|
31
|
+
isSamplerInstrument,
|
|
32
|
+
type TrackScore,
|
|
33
|
+
} from "../../core/score.ts";
|
|
34
|
+
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
35
|
+
import { WavFormatError, parseWav } from "../media/vendor/wav.ts";
|
|
36
|
+
import { WorkspaceError, resolveReadPath } from "../agent/workspace.ts";
|
|
37
|
+
|
|
38
|
+
/** One decoded sample: source rate, mono mixdown for the renderer. */
|
|
39
|
+
export type DecodedSample = Readonly<{
|
|
40
|
+
sha256: string;
|
|
41
|
+
sampleRate: number;
|
|
42
|
+
/** Channels in the source file (the renderer plays a mono mixdown). */
|
|
43
|
+
channels: number;
|
|
44
|
+
frames: number;
|
|
45
|
+
mono: Float32Array;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
export type SampleProblem = Readonly<{
|
|
49
|
+
trackId: string;
|
|
50
|
+
voice: string;
|
|
51
|
+
src: string;
|
|
52
|
+
level: "error" | "warning";
|
|
53
|
+
/** `<what> · <why> · <next step>`. */
|
|
54
|
+
message: string;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
/** Decoded voices keyed by `sampleKey(trackId, voice)`, plus load problems. */
|
|
58
|
+
export type SampleBank = Readonly<{
|
|
59
|
+
voices: ReadonlyMap<string, DecodedSample>;
|
|
60
|
+
problems: readonly SampleProblem[];
|
|
61
|
+
}>;
|
|
62
|
+
|
|
63
|
+
export const EMPTY_SAMPLE_BANK: SampleBank = Object.freeze({
|
|
64
|
+
voices: new Map<string, DecodedSample>(),
|
|
65
|
+
problems: Object.freeze([]),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** True when any track of the score is a sampler with voices. */
|
|
69
|
+
export function hasSamplerTracks(score: TrackScore): boolean {
|
|
70
|
+
return score.tracks.some(
|
|
71
|
+
(track) =>
|
|
72
|
+
isSamplerInstrument(track.instrument) &&
|
|
73
|
+
track.sampler !== undefined &&
|
|
74
|
+
Object.keys(track.sampler.voices).length > 0,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sampleKey(trackId: string, voice: string): string {
|
|
79
|
+
return `${trackId}\u0000${voice}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Anything that resolves sample voices for a score (the engine's hook). */
|
|
83
|
+
export interface SampleSource {
|
|
84
|
+
load(score: TrackScore): Promise<SampleBank>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Interleaved float PCM at the file's own rate. */
|
|
88
|
+
export type PcmData = Readonly<{
|
|
89
|
+
sampleRate: number;
|
|
90
|
+
channels: number;
|
|
91
|
+
frames: number;
|
|
92
|
+
data: Float32Array;
|
|
93
|
+
}>;
|
|
94
|
+
|
|
95
|
+
export class SampleDecodeError extends Error {
|
|
96
|
+
constructor(message: string) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = "SampleDecodeError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const MIN_RATE = 8_000;
|
|
103
|
+
const MAX_RATE = 192_000;
|
|
104
|
+
const MAX_CHANNELS = 8;
|
|
105
|
+
/** ffmpeg output: fixed so the cache does not depend on probing. */
|
|
106
|
+
const FFMPEG_RATE = 48_000;
|
|
107
|
+
const FFMPEG_CHANNELS = 2;
|
|
108
|
+
const FFMPEG_TIMEOUT_MS = 60_000;
|
|
109
|
+
const CACHE_MAGIC = 0x4d435044; // "DPCM" little-endian
|
|
110
|
+
const CACHE_VERSION = 1;
|
|
111
|
+
const CACHE_HEADER_BYTES = 16;
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Native decoders
|
|
115
|
+
|
|
116
|
+
function ascii(bytes: Uint8Array, offset: number, count: number): string {
|
|
117
|
+
return String.fromCharCode(...bytes.subarray(offset, offset + count));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The container a file's first bytes announce, if dawg decodes it natively. */
|
|
121
|
+
export function nativeFormat(bytes: Uint8Array): "wav" | "aiff" | undefined {
|
|
122
|
+
if (bytes.byteLength < 12) return undefined;
|
|
123
|
+
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE")
|
|
124
|
+
return "wav";
|
|
125
|
+
const form = ascii(bytes, 8, 4);
|
|
126
|
+
if (ascii(bytes, 0, 4) === "FORM" && (form === "AIFF" || form === "AIFC"))
|
|
127
|
+
return "aiff";
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** WAV through the vendored media parser (PCM 16/24/32, float32). */
|
|
132
|
+
export function decodeWav(bytes: Uint8Array): PcmData {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseWav(bytes, {
|
|
136
|
+
maximumBytes: SCORE_LIMITS.maxSampleFileBytes,
|
|
137
|
+
maximumDurationSeconds: SCORE_LIMITS.maxSampleSeconds,
|
|
138
|
+
maximumChannels: MAX_CHANNELS,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof WavFormatError && error.code === "too_long")
|
|
142
|
+
throw new SampleLimitError(
|
|
143
|
+
`longer than the ${SCORE_LIMITS.maxSampleSeconds / 60} minute sample limit`,
|
|
144
|
+
);
|
|
145
|
+
if (error instanceof WavFormatError)
|
|
146
|
+
throw new SampleDecodeError(error.message.replace(/\.$/, ""));
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
const { sampleRate, channels, sampleCount: frames } = parsed;
|
|
150
|
+
const data = new Float32Array(frames * channels);
|
|
151
|
+
for (let frame = 0; frame < frames; frame += 1)
|
|
152
|
+
for (let channel = 0; channel < channels; channel += 1)
|
|
153
|
+
data[frame * channels + channel] = parsed.sample(frame, channel);
|
|
154
|
+
return { sampleRate, channels, frames, data };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** IEEE 754 80-bit extended (AIFF sample rate). */
|
|
158
|
+
function extended80(view: DataView, offset: number): number {
|
|
159
|
+
const exponent = view.getUint16(offset) & 0x7fff;
|
|
160
|
+
const hi = view.getUint32(offset + 2);
|
|
161
|
+
const lo = view.getUint32(offset + 6);
|
|
162
|
+
if (exponent === 0 && hi === 0 && lo === 0) return 0;
|
|
163
|
+
return (hi * 2 ** 32 + lo) * 2 ** (exponent - 16383 - 63);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** AIFF and AIFC (`NONE`, `sowt`, `fl32`/`FL32`). */
|
|
167
|
+
export function decodeAiff(bytes: Uint8Array): PcmData {
|
|
168
|
+
if (nativeFormat(bytes) !== "aiff")
|
|
169
|
+
throw new SampleDecodeError("file is not AIFF");
|
|
170
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
171
|
+
const aifc = ascii(bytes, 8, 4) === "AIFC";
|
|
172
|
+
let channels = 0;
|
|
173
|
+
let frames = 0;
|
|
174
|
+
let bits = 0;
|
|
175
|
+
let sampleRate = 0;
|
|
176
|
+
let compression = "NONE";
|
|
177
|
+
let dataStart = -1;
|
|
178
|
+
let offset = 12;
|
|
179
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
180
|
+
const id = ascii(bytes, offset, 4);
|
|
181
|
+
const size = view.getUint32(offset + 4);
|
|
182
|
+
const start = offset + 8;
|
|
183
|
+
if (start + size > bytes.byteLength)
|
|
184
|
+
throw new SampleDecodeError("AIFF contains a truncated chunk");
|
|
185
|
+
if (id === "COMM") {
|
|
186
|
+
if (size < 18) throw new SampleDecodeError("AIFF COMM chunk is short");
|
|
187
|
+
channels = view.getUint16(start);
|
|
188
|
+
frames = view.getUint32(start + 2);
|
|
189
|
+
bits = view.getUint16(start + 6);
|
|
190
|
+
sampleRate = extended80(view, start + 8);
|
|
191
|
+
if (aifc && size >= 22) compression = ascii(bytes, start + 18, 4);
|
|
192
|
+
} else if (id === "SSND") {
|
|
193
|
+
const dataOffset = view.getUint32(start);
|
|
194
|
+
dataStart = start + 8 + dataOffset;
|
|
195
|
+
}
|
|
196
|
+
offset = start + size + (size & 1);
|
|
197
|
+
}
|
|
198
|
+
if (dataStart < 0 || channels === 0)
|
|
199
|
+
throw new SampleDecodeError("AIFF is missing COMM or SSND");
|
|
200
|
+
const float = compression === "fl32" || compression === "FL32";
|
|
201
|
+
const little = compression === "sowt";
|
|
202
|
+
if (!float && !little && compression !== "NONE")
|
|
203
|
+
throw new SampleDecodeError(`AIFC compression ${compression} is not PCM`);
|
|
204
|
+
if (float) bits = 32;
|
|
205
|
+
if (![8, 16, 24, 32].includes(bits))
|
|
206
|
+
throw new SampleDecodeError(`AIFF ${bits}-bit samples are unsupported`);
|
|
207
|
+
if (little && bits !== 16)
|
|
208
|
+
throw new SampleDecodeError("AIFC sowt must be 16-bit");
|
|
209
|
+
checkShape(sampleRate, channels, frames);
|
|
210
|
+
const width = bits / 8;
|
|
211
|
+
if (dataStart + frames * channels * width > bytes.byteLength)
|
|
212
|
+
throw new SampleDecodeError("AIFF sound data is truncated");
|
|
213
|
+
const data = new Float32Array(frames * channels);
|
|
214
|
+
for (let index = 0; index < frames * channels; index += 1) {
|
|
215
|
+
const at = dataStart + index * width;
|
|
216
|
+
let value: number;
|
|
217
|
+
if (float) value = Math.max(-1, Math.min(1, view.getFloat32(at)));
|
|
218
|
+
else if (bits === 8) value = view.getInt8(at) / 128;
|
|
219
|
+
else if (bits === 16) value = view.getInt16(at, little) / 32_768;
|
|
220
|
+
else if (bits === 24) {
|
|
221
|
+
let raw = (bytes[at]! << 16) | (bytes[at + 1]! << 8) | bytes[at + 2]!;
|
|
222
|
+
if (raw & 0x800000) raw |= ~0xffffff;
|
|
223
|
+
value = raw / 8_388_608;
|
|
224
|
+
} else value = view.getInt32(at) / 2_147_483_648;
|
|
225
|
+
data[index] = value;
|
|
226
|
+
}
|
|
227
|
+
return { sampleRate, channels, frames, data };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function checkShape(sampleRate: number, channels: number, frames: number) {
|
|
231
|
+
if (
|
|
232
|
+
!Number.isFinite(sampleRate) ||
|
|
233
|
+
sampleRate < MIN_RATE ||
|
|
234
|
+
sampleRate > MAX_RATE
|
|
235
|
+
)
|
|
236
|
+
throw new SampleDecodeError(
|
|
237
|
+
`sample rate ${Math.round(sampleRate)} Hz is outside ${MIN_RATE}–${MAX_RATE} Hz`,
|
|
238
|
+
);
|
|
239
|
+
if (channels < 1 || channels > MAX_CHANNELS)
|
|
240
|
+
throw new SampleDecodeError(`${channels} channels is unsupported`);
|
|
241
|
+
if (frames / sampleRate > SCORE_LIMITS.maxSampleSeconds)
|
|
242
|
+
throw new SampleDecodeError(
|
|
243
|
+
`longer than ${SCORE_LIMITS.maxSampleSeconds / 60} minutes`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Average the channels; the renderer plays sampler voices in mono before pan. */
|
|
248
|
+
export function monoMix(pcm: PcmData): Float32Array {
|
|
249
|
+
if (pcm.channels === 1) return pcm.data;
|
|
250
|
+
const mono = new Float32Array(pcm.frames);
|
|
251
|
+
for (let frame = 0; frame < pcm.frames; frame += 1) {
|
|
252
|
+
let sum = 0;
|
|
253
|
+
for (let channel = 0; channel < pcm.channels; channel += 1)
|
|
254
|
+
sum += pcm.data[frame * pcm.channels + channel]!;
|
|
255
|
+
mono[frame] = sum / pcm.channels;
|
|
256
|
+
}
|
|
257
|
+
return mono;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Linear-interpolated resampling of a mono buffer. The renderer resamples on
|
|
262
|
+
* the fly (rate = speed × source/engine rate); this is the same arithmetic
|
|
263
|
+
* for callers that want a buffer at the engine rate.
|
|
264
|
+
*/
|
|
265
|
+
export function resampleLinear(
|
|
266
|
+
input: Float32Array,
|
|
267
|
+
fromRate: number,
|
|
268
|
+
toRate: number,
|
|
269
|
+
): Float32Array {
|
|
270
|
+
if (fromRate === toRate || input.length === 0) return input.slice();
|
|
271
|
+
const length = Math.max(1, Math.round((input.length * toRate) / fromRate));
|
|
272
|
+
const out = new Float32Array(length);
|
|
273
|
+
const step = fromRate / toRate;
|
|
274
|
+
for (let index = 0; index < length; index += 1) {
|
|
275
|
+
const position = index * step;
|
|
276
|
+
const base = Math.floor(position);
|
|
277
|
+
const next = Math.min(input.length - 1, base + 1);
|
|
278
|
+
const fraction = position - base;
|
|
279
|
+
const a = input[Math.min(input.length - 1, base)]!;
|
|
280
|
+
out[index] = a + (input[next]! - a) * fraction;
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// ffmpeg
|
|
287
|
+
|
|
288
|
+
export type FfmpegRunner = (
|
|
289
|
+
args: readonly string[],
|
|
290
|
+
options: Readonly<{ timeoutMs: number; maxBytes: number }>,
|
|
291
|
+
) => Promise<Readonly<{ code: number; stdout: Uint8Array; stderr: string }>>;
|
|
292
|
+
|
|
293
|
+
const bunFfmpeg: FfmpegRunner = async (args, options) => {
|
|
294
|
+
const proc = Bun.spawn([...args], {
|
|
295
|
+
stdin: "ignore",
|
|
296
|
+
stdout: "pipe",
|
|
297
|
+
stderr: "pipe",
|
|
298
|
+
});
|
|
299
|
+
const timer = setTimeout(() => proc.kill(), options.timeoutMs);
|
|
300
|
+
try {
|
|
301
|
+
const chunks: Uint8Array[] = [];
|
|
302
|
+
let total = 0;
|
|
303
|
+
for await (const chunk of proc.stdout) {
|
|
304
|
+
total += chunk.byteLength;
|
|
305
|
+
if (total > options.maxBytes) {
|
|
306
|
+
proc.kill();
|
|
307
|
+
throw new SampleDecodeError(
|
|
308
|
+
`decoded audio is longer than ${SCORE_LIMITS.maxSampleSeconds / 60} minutes`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
chunks.push(chunk);
|
|
312
|
+
}
|
|
313
|
+
const stderr = await new Response(proc.stderr).text();
|
|
314
|
+
const code = await proc.exited;
|
|
315
|
+
const stdout = new Uint8Array(total);
|
|
316
|
+
let at = 0;
|
|
317
|
+
for (const chunk of chunks) {
|
|
318
|
+
stdout.set(chunk, at);
|
|
319
|
+
at += chunk.byteLength;
|
|
320
|
+
}
|
|
321
|
+
return { code, stdout, stderr };
|
|
322
|
+
} finally {
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Decode any ffmpeg-readable file to 48 kHz stereo float. */
|
|
328
|
+
export async function decodeWithFfmpeg(
|
|
329
|
+
ffmpeg: string,
|
|
330
|
+
path: string,
|
|
331
|
+
run: FfmpegRunner = bunFfmpeg,
|
|
332
|
+
): Promise<PcmData> {
|
|
333
|
+
const maxBytes =
|
|
334
|
+
SCORE_LIMITS.maxSampleSeconds * FFMPEG_RATE * FFMPEG_CHANNELS * 4 + 4096;
|
|
335
|
+
const result = await run(
|
|
336
|
+
[
|
|
337
|
+
ffmpeg,
|
|
338
|
+
"-hide_banner",
|
|
339
|
+
"-nostdin",
|
|
340
|
+
"-loglevel",
|
|
341
|
+
"error",
|
|
342
|
+
"-i",
|
|
343
|
+
path,
|
|
344
|
+
"-vn",
|
|
345
|
+
"-t",
|
|
346
|
+
String(SCORE_LIMITS.maxSampleSeconds),
|
|
347
|
+
"-f",
|
|
348
|
+
"f32le",
|
|
349
|
+
"-acodec",
|
|
350
|
+
"pcm_f32le",
|
|
351
|
+
"-ac",
|
|
352
|
+
String(FFMPEG_CHANNELS),
|
|
353
|
+
"-ar",
|
|
354
|
+
String(FFMPEG_RATE),
|
|
355
|
+
"-",
|
|
356
|
+
],
|
|
357
|
+
{ timeoutMs: FFMPEG_TIMEOUT_MS, maxBytes },
|
|
358
|
+
);
|
|
359
|
+
if (result.code !== 0) {
|
|
360
|
+
const line = result.stderr.trim().split("\n").pop()?.slice(0, 160);
|
|
361
|
+
throw new SampleDecodeError(
|
|
362
|
+
`ffmpeg could not decode it${line ? ` (${line})` : ""}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const frames = Math.floor(result.stdout.byteLength / (4 * FFMPEG_CHANNELS));
|
|
366
|
+
if (frames === 0) throw new SampleDecodeError("ffmpeg produced no audio");
|
|
367
|
+
const data = new Float32Array(frames * FFMPEG_CHANNELS);
|
|
368
|
+
const view = new DataView(
|
|
369
|
+
result.stdout.buffer,
|
|
370
|
+
result.stdout.byteOffset,
|
|
371
|
+
frames * FFMPEG_CHANNELS * 4,
|
|
372
|
+
);
|
|
373
|
+
for (let index = 0; index < data.length; index += 1)
|
|
374
|
+
data[index] = Math.max(-1, Math.min(1, view.getFloat32(index * 4, true)));
|
|
375
|
+
return { sampleRate: FFMPEG_RATE, channels: FFMPEG_CHANNELS, frames, data };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Content-addressed PCM cache
|
|
380
|
+
|
|
381
|
+
export function encodeCachedPcm(pcm: PcmData): Uint8Array {
|
|
382
|
+
const out = new Uint8Array(CACHE_HEADER_BYTES + pcm.data.length * 4);
|
|
383
|
+
const view = new DataView(out.buffer);
|
|
384
|
+
view.setUint32(0, CACHE_MAGIC, true);
|
|
385
|
+
view.setUint32(4, CACHE_VERSION, true);
|
|
386
|
+
view.setUint32(8, pcm.sampleRate, true);
|
|
387
|
+
view.setUint32(12, pcm.channels, true);
|
|
388
|
+
for (let index = 0; index < pcm.data.length; index += 1)
|
|
389
|
+
view.setFloat32(CACHE_HEADER_BYTES + index * 4, pcm.data[index]!, true);
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function decodeCachedPcm(bytes: Uint8Array): PcmData | undefined {
|
|
394
|
+
if (bytes.byteLength < CACHE_HEADER_BYTES) return undefined;
|
|
395
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
396
|
+
if (
|
|
397
|
+
view.getUint32(0, true) !== CACHE_MAGIC ||
|
|
398
|
+
view.getUint32(4, true) !== CACHE_VERSION
|
|
399
|
+
)
|
|
400
|
+
return undefined;
|
|
401
|
+
const sampleRate = view.getUint32(8, true);
|
|
402
|
+
const channels = view.getUint32(12, true);
|
|
403
|
+
const body = bytes.byteLength - CACHE_HEADER_BYTES;
|
|
404
|
+
if (channels < 1 || channels > MAX_CHANNELS || body % (4 * channels) !== 0)
|
|
405
|
+
return undefined;
|
|
406
|
+
const data = new Float32Array(body / 4);
|
|
407
|
+
for (let index = 0; index < data.length; index += 1)
|
|
408
|
+
data[index] = view.getFloat32(CACHE_HEADER_BYTES + index * 4, true);
|
|
409
|
+
return { sampleRate, channels, frames: data.length / channels, data };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Delete least recently used `.pcm` files until the directory fits `maxBytes`. */
|
|
413
|
+
export async function pruneAssetCache(
|
|
414
|
+
dir: string,
|
|
415
|
+
maxBytes: number,
|
|
416
|
+
keep?: string,
|
|
417
|
+
): 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;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Library
|
|
447
|
+
|
|
448
|
+
export type SampleLibraryOptions = Readonly<{
|
|
449
|
+
/** Absolute project root; sample paths must stay inside it. */
|
|
450
|
+
projectRoot: string;
|
|
451
|
+
/** Default `<projectRoot>/.dawg/assets`. */
|
|
452
|
+
cacheDir?: string;
|
|
453
|
+
/** Disk and memory cache budget; default `SCORE_LIMITS.maxSampleCacheBytes`. */
|
|
454
|
+
maxCacheBytes?: number;
|
|
455
|
+
/** ffmpeg binary, `null` for none; default `Bun.which("ffmpeg")`. */
|
|
456
|
+
ffmpeg?: string | null;
|
|
457
|
+
runFfmpeg?: FfmpegRunner;
|
|
458
|
+
}>;
|
|
459
|
+
|
|
460
|
+
type MemoryEntry = { sample: DecodedSample; bytes: number };
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Loads a score's sample voices. Decoded samples stay in an in-memory LRU
|
|
464
|
+
* keyed by content hash, so re-renders after an edit cost a `stat` per voice.
|
|
465
|
+
*/
|
|
466
|
+
export class SampleLibrary implements SampleSource {
|
|
467
|
+
public readonly projectRoot: string;
|
|
468
|
+
public readonly cacheDir: string;
|
|
469
|
+
private readonly maxCacheBytes: number;
|
|
470
|
+
private readonly ffmpeg: string | null;
|
|
471
|
+
private readonly runFfmpeg: FfmpegRunner | undefined;
|
|
472
|
+
private readonly memory = new Map<string, MemoryEntry>();
|
|
473
|
+
private memoryBytes = 0;
|
|
474
|
+
/** `real:size:mtime` → sha256, so unchanged files are not re-hashed. */
|
|
475
|
+
private readonly hashes = new Map<string, string>();
|
|
476
|
+
private stats = { decodes: 0, diskHits: 0, memoryHits: 0 };
|
|
477
|
+
|
|
478
|
+
public constructor(options: SampleLibraryOptions) {
|
|
479
|
+
this.projectRoot = options.projectRoot;
|
|
480
|
+
this.cacheDir =
|
|
481
|
+
options.cacheDir ?? join(options.projectRoot, ".dawg", "assets");
|
|
482
|
+
this.maxCacheBytes = Math.max(
|
|
483
|
+
0,
|
|
484
|
+
options.maxCacheBytes ?? SCORE_LIMITS.maxSampleCacheBytes,
|
|
485
|
+
);
|
|
486
|
+
this.ffmpeg =
|
|
487
|
+
options.ffmpeg === undefined
|
|
488
|
+
? (Bun.which("ffmpeg") ?? null)
|
|
489
|
+
: options.ffmpeg;
|
|
490
|
+
this.runFfmpeg = options.runFfmpeg;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Decode/cache counters, for tests and diagnostics. */
|
|
494
|
+
public get counters(): Readonly<{
|
|
495
|
+
decodes: number;
|
|
496
|
+
diskHits: number;
|
|
497
|
+
memoryHits: number;
|
|
498
|
+
}> {
|
|
499
|
+
return { ...this.stats };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
public async load(score: TrackScore): Promise<SampleBank> {
|
|
503
|
+
const voices = new Map<string, DecodedSample>();
|
|
504
|
+
const problems: SampleProblem[] = [];
|
|
505
|
+
let dirs: ReadonlyMap<string, string> | undefined;
|
|
506
|
+
for (const track of score.tracks) {
|
|
507
|
+
if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
|
|
508
|
+
const names = Object.keys(track.sampler.voices).sort();
|
|
509
|
+
for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices)) {
|
|
510
|
+
const ref = track.sampler.voices[voice]!;
|
|
511
|
+
let src = ref.src;
|
|
512
|
+
if (!src.startsWith("tracks/")) {
|
|
513
|
+
dirs ??= trackDirectories(score);
|
|
514
|
+
src = `tracks/${dirs.get(track.id) ?? track.id}/${src.replace(/^\.\//, "")}`;
|
|
515
|
+
}
|
|
516
|
+
const report = (level: SampleProblem["level"], message: string) =>
|
|
517
|
+
problems.push({
|
|
518
|
+
trackId: track.id,
|
|
519
|
+
voice,
|
|
520
|
+
src: ref.src,
|
|
521
|
+
level,
|
|
522
|
+
message,
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
const loaded = await this.loadFile(src);
|
|
526
|
+
if (ref.sha256 && ref.sha256 !== loaded.sha256)
|
|
527
|
+
report(
|
|
528
|
+
"warning",
|
|
529
|
+
`${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`,
|
|
530
|
+
);
|
|
531
|
+
voices.set(sampleKey(track.id, voice), loaded);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
report("error", describeFailure(voice, src, error, this.ffmpeg));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return Object.freeze({ voices, problems: Object.freeze(problems) });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async loadFile(src: string): Promise<DecodedSample> {
|
|
541
|
+
const resolved = await resolveReadPath(
|
|
542
|
+
{ root: this.projectRoot, trackSlug: "" },
|
|
543
|
+
src,
|
|
544
|
+
"sample",
|
|
545
|
+
);
|
|
546
|
+
const info = await stat(resolved.real);
|
|
547
|
+
if (!info.isFile()) throw new SampleDecodeError("not a regular file");
|
|
548
|
+
if (info.size > SCORE_LIMITS.maxSampleFileBytes)
|
|
549
|
+
throw new SampleLimitError(
|
|
550
|
+
`${formatMiB(info.size)} is over the ${formatMiB(SCORE_LIMITS.maxSampleFileBytes)} sample file limit`,
|
|
551
|
+
);
|
|
552
|
+
const statKey = `${resolved.real}:${info.size}:${info.mtimeMs}`;
|
|
553
|
+
const knownSha = this.hashes.get(statKey);
|
|
554
|
+
if (knownSha) {
|
|
555
|
+
const hit = this.fromMemory(knownSha);
|
|
556
|
+
if (hit) return hit;
|
|
557
|
+
}
|
|
558
|
+
const bytes = new Uint8Array(await readFile(resolved.real));
|
|
559
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
560
|
+
this.hashes.set(statKey, sha256);
|
|
561
|
+
if (this.hashes.size > 4096) this.hashes.clear();
|
|
562
|
+
const hit = this.fromMemory(sha256);
|
|
563
|
+
if (hit) return hit;
|
|
564
|
+
let pcm = await this.fromDisk(sha256);
|
|
565
|
+
if (pcm) this.stats.diskHits += 1;
|
|
566
|
+
else {
|
|
567
|
+
pcm = await this.decode(bytes, resolved.real);
|
|
568
|
+
this.stats.decodes += 1;
|
|
569
|
+
await this.toDisk(sha256, pcm);
|
|
570
|
+
}
|
|
571
|
+
if (pcm.frames / pcm.sampleRate > SCORE_LIMITS.maxSampleSeconds)
|
|
572
|
+
throw new SampleLimitError(
|
|
573
|
+
`${Math.round(pcm.frames / pcm.sampleRate)} s is over the ${SCORE_LIMITS.maxSampleSeconds / 60} minute sample limit`,
|
|
574
|
+
);
|
|
575
|
+
if (pcm.frames === 0) throw new SampleDecodeError("it contains no audio");
|
|
576
|
+
const sample: DecodedSample = Object.freeze({
|
|
577
|
+
sha256,
|
|
578
|
+
sampleRate: pcm.sampleRate,
|
|
579
|
+
channels: pcm.channels,
|
|
580
|
+
frames: pcm.frames,
|
|
581
|
+
mono: monoMix(pcm),
|
|
582
|
+
});
|
|
583
|
+
this.remember(sample);
|
|
584
|
+
return sample;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async decode(bytes: Uint8Array, path: string): Promise<PcmData> {
|
|
588
|
+
const format = nativeFormat(bytes);
|
|
589
|
+
if (format === "wav") return decodeWav(bytes);
|
|
590
|
+
if (format === "aiff") return decodeAiff(bytes);
|
|
591
|
+
if (!this.ffmpeg) throw new FfmpegMissingError();
|
|
592
|
+
return decodeWithFfmpeg(this.ffmpeg, path, this.runFfmpeg);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fromMemory(sha256: string): DecodedSample | undefined {
|
|
596
|
+
const entry = this.memory.get(sha256);
|
|
597
|
+
if (!entry) return undefined;
|
|
598
|
+
this.memory.delete(sha256);
|
|
599
|
+
this.memory.set(sha256, entry);
|
|
600
|
+
this.stats.memoryHits += 1;
|
|
601
|
+
return entry.sample;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private remember(sample: DecodedSample): void {
|
|
605
|
+
const bytes = sample.mono.byteLength;
|
|
606
|
+
this.memory.set(sample.sha256, { sample, bytes });
|
|
607
|
+
this.memoryBytes += bytes;
|
|
608
|
+
for (const [sha, entry] of this.memory) {
|
|
609
|
+
if (this.memoryBytes <= this.maxCacheBytes) break;
|
|
610
|
+
if (sha === sample.sha256) continue;
|
|
611
|
+
this.memory.delete(sha);
|
|
612
|
+
this.memoryBytes -= entry.bytes;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async fromDisk(sha256: string): Promise<PcmData | undefined> {
|
|
617
|
+
const path = join(this.cacheDir, `${sha256}.pcm`);
|
|
618
|
+
try {
|
|
619
|
+
const pcm = decodeCachedPcm(new Uint8Array(await readFile(path)));
|
|
620
|
+
if (!pcm) return undefined;
|
|
621
|
+
const now = new Date();
|
|
622
|
+
await utimes(path, now, now).catch(() => undefined);
|
|
623
|
+
return pcm;
|
|
624
|
+
} catch {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async toDisk(sha256: string, pcm: PcmData): Promise<void> {
|
|
630
|
+
const size = CACHE_HEADER_BYTES + pcm.data.length * 4;
|
|
631
|
+
if (size > this.maxCacheBytes) return;
|
|
632
|
+
try {
|
|
633
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
634
|
+
const path = join(this.cacheDir, `${sha256}.pcm`);
|
|
635
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
636
|
+
await writeFile(temporary, encodeCachedPcm(pcm));
|
|
637
|
+
await rename(temporary, path);
|
|
638
|
+
await pruneAssetCache(this.cacheDir, this.maxCacheBytes, sha256);
|
|
639
|
+
} catch {
|
|
640
|
+
/* the cache is an optimisation; a read-only project still renders */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export class SampleLimitError extends Error {
|
|
646
|
+
constructor(message: string) {
|
|
647
|
+
super(message);
|
|
648
|
+
this.name = "SampleLimitError";
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
class FfmpegMissingError extends Error {
|
|
653
|
+
constructor() {
|
|
654
|
+
super("ffmpeg is not on PATH");
|
|
655
|
+
this.name = "FfmpegMissingError";
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function formatMiB(bytes: number): string {
|
|
660
|
+
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MiB`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function describeFailure(
|
|
664
|
+
voice: string,
|
|
665
|
+
src: string,
|
|
666
|
+
error: unknown,
|
|
667
|
+
ffmpeg: string | null,
|
|
668
|
+
): string {
|
|
669
|
+
const what = `${voice} · ${src}`;
|
|
670
|
+
if (error instanceof WorkspaceError) {
|
|
671
|
+
if (/no such file/.test(error.message))
|
|
672
|
+
return `${what} · file is missing · add it under the track's samples/ or fix src in track.ts`;
|
|
673
|
+
return `${what} · ${error.message} · keep samples inside the project, e.g. tracks/<slug>/samples/`;
|
|
674
|
+
}
|
|
675
|
+
if (error instanceof FfmpegMissingError)
|
|
676
|
+
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`;
|
|
677
|
+
if (error instanceof SampleLimitError)
|
|
678
|
+
return `${what} · ${error.message} · trim it or import a shorter section`;
|
|
679
|
+
if (error instanceof SampleDecodeError)
|
|
680
|
+
return `${what} · ${error.message} · ${ffmpeg ? "re-export it as 16/24-bit WAV" : "re-export it as 16/24-bit WAV, or install ffmpeg"}`;
|
|
681
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
682
|
+
return `${what} · ${message.slice(0, 160)} · check the file and reload`;
|
|
683
|
+
}
|