engine-dj-mcp 0.9.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/blobs/index.d.ts +337 -0
  4. package/dist/blobs/index.js +483 -0
  5. package/dist/blobs/qcompress.d.ts +44 -0
  6. package/dist/blobs/qcompress.js +146 -0
  7. package/dist/discovery.d.ts +36 -0
  8. package/dist/discovery.js +111 -0
  9. package/dist/errors.d.ts +30 -0
  10. package/dist/errors.js +49 -0
  11. package/dist/guard.d.ts +31 -0
  12. package/dist/guard.js +236 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +13 -0
  15. package/dist/library-select.d.ts +63 -0
  16. package/dist/library-select.js +97 -0
  17. package/dist/paths.d.ts +18 -0
  18. package/dist/paths.js +34 -0
  19. package/dist/probe.d.ts +7 -0
  20. package/dist/probe.js +20 -0
  21. package/dist/proc/query-client.d.ts +36 -0
  22. package/dist/proc/query-client.js +249 -0
  23. package/dist/proc/query-worker.d.ts +1 -0
  24. package/dist/proc/query-worker.js +72 -0
  25. package/dist/semantics.d.ts +43 -0
  26. package/dist/semantics.js +95 -0
  27. package/dist/server.d.ts +31 -0
  28. package/dist/server.js +439 -0
  29. package/dist/sidecar/build.d.ts +19 -0
  30. package/dist/sidecar/build.js +85 -0
  31. package/dist/sidecar/schema.d.ts +25 -0
  32. package/dist/sidecar/schema.js +36 -0
  33. package/dist/store/connections.d.ts +28 -0
  34. package/dist/store/connections.js +116 -0
  35. package/dist/store/index-manager.d.ts +29 -0
  36. package/dist/store/index-manager.js +187 -0
  37. package/dist/tools/audit.d.ts +15 -0
  38. package/dist/tools/audit.js +148 -0
  39. package/dist/tools/libraries.d.ts +40 -0
  40. package/dist/tools/libraries.js +30 -0
  41. package/dist/tools/performance.d.ts +15 -0
  42. package/dist/tools/performance.js +47 -0
  43. package/dist/tools/refresh.d.ts +8 -0
  44. package/dist/tools/refresh.js +3 -0
  45. package/dist/tools/search.d.ts +60 -0
  46. package/dist/tools/search.js +328 -0
  47. package/dist/tools/sql.d.ts +14 -0
  48. package/dist/tools/sql.js +21 -0
  49. package/dist/tools/tracks.d.ts +12 -0
  50. package/dist/tools/tracks.js +49 -0
  51. package/package.json +53 -0
@@ -0,0 +1,483 @@
1
+ // src/blobs/index.ts
2
+ //
3
+ // Decoders for Engine's PerformanceData blob columns (overviewWaveFormData,
4
+ // beatData, quickCues, loops).
5
+ //
6
+ // The layouts below were derived from, and checked against, a real Engine DJ
7
+ // library: 257 analysed tracks on a USB export plus 24 in the local history
8
+ // database, 281 blobs of each kind. What each check proved is recorded next
9
+ // to the decoder it justifies. `loops` is the one field whose *contents*
10
+ // could not be checked — see decodeLoops.
11
+ //
12
+ // Framing: `quickCues`, `beatData` and `overviewWaveFormData` are Qt
13
+ // qCompress frames (4-byte big-endian uncompressed length, then a raw zlib
14
+ // stream); the declared length matched the inflated length for all 281 of
15
+ // each. `loops` is NOT compressed and NOT framed — it is 192 raw bytes, and
16
+ // running it through the qCompress path reads its little-endian int64 count
17
+ // of 8 as a big-endian length of 134217728 and hands zlib bytes it rejects.
18
+ //
19
+ // The defensive envelope is unchanged by the layouts becoming known, because
20
+ // the blobs are not necessarily the user's own: defaultRoots() scans
21
+ // /Volumes, so a USB stick prepared by someone else is an ordinary input.
22
+ // - every buffer read is bounds-checked (Reader#need in qcompress.ts)
23
+ // - every count/length read from the blob is sanity-capped before use
24
+ // - every decoded float is required to be finite
25
+ // - one field's failure never prevents its siblings from decoding
26
+ // - responses stay bounded regardless of what a blob claims
27
+ import { qUncompress, Reader, DecodeError } from "./qcompress.js";
28
+ /**
29
+ * Whether a field's binary layout has been checked against real Engine data.
30
+ *
31
+ * `"verified"` means the layout was derived from a real library and confirmed
32
+ * by predictions that could have failed: cue positions landing inside the
33
+ * track, a beatgrid whose implied tempo matches `Track.bpmAnalyzed`, a
34
+ * waveform whose declared bucket spacing multiplies back out to the track's
35
+ * sample count. `status: "ok"` on a verified field is a claim about the
36
+ * values, not merely about the parse.
37
+ *
38
+ * `"unverified"` still means what it always did: the bytes parsed without
39
+ * contradicting the layout, and nothing more. It survives on `loops` because
40
+ * no track in the 281 examined has a loop set, so while the slot structure is
41
+ * pinned down, the meaning of a *populated* slot is untested.
42
+ *
43
+ * The marker is about the *bytes*: which offset holds which field, and what
44
+ * the numbers there mean. It is not a claim about every English word this
45
+ * module attaches to them. Three labels are inferred rather than measured and
46
+ * say so at their own definitions — the cue colour's channel order, the
47
+ * beatgrid's `grid: "adjusted"` and `main_cue.is_adjusted` naming, and the
48
+ * waveform's low/mid/high band naming. Each names a field whose *value* is
49
+ * pinned by the evidence below; only the name is a reading of it.
50
+ */
51
+ export const LAYOUT_VERIFIED = "verified";
52
+ export const LAYOUT_UNVERIFIED = "unverified";
53
+ // Sanity bounds: a corrupt length/count field must be refused rather than
54
+ // attempted, however large it claims to be. These are generous compared to
55
+ // the real values measured (8 cue slots, 8 loop slots, 2 beat anchors per
56
+ // grid, 1024 waveform points), which never come close to them.
57
+ //
58
+ // These are *parse* bounds, not response bounds — see MAX_RETURNED below.
59
+ const MAX_ITEMS = 512; // a real cue or loop list is nowhere near this
60
+ const MAX_ANCHORS = 8192; // a real beatgrid is nowhere near this either
61
+ const MAX_WAVEFORM_ENTRIES = 1 << 20; // 1024 in every blob measured
62
+ /**
63
+ * How many items of a decoded field actually reach the caller. The parse
64
+ * bounds above have to be generous enough to accept anything real; this one
65
+ * exists because the result lands in an LLM's context, and `reply()`
66
+ * serialises it twice (once as text, once as structuredContent). 8192
67
+ * beatgrid anchors — reachable from a single corrupt count — is roughly a
68
+ * megabyte of context spent on numbers nobody asked for. audit_library
69
+ * already answers with a count plus a sample of ten for the same reason.
70
+ */
71
+ const MAX_RETURNED = 64;
72
+ /** Engine's "this slot is empty" marker, in every cue and loop slot measured. */
73
+ const UNSET = -1;
74
+ /** Reads a float64 that must be finite; a NaN/Infinity here means the
75
+ * offset landed on the wrong bytes (corrupt data or a wrong layout), not a
76
+ * legitimate value — silently returning NaN would later serialise as `null`
77
+ * in JSON and read as "no value" rather than "decode failed". */
78
+ function finiteF64(r, what) {
79
+ const v = r.f64();
80
+ if (!Number.isFinite(v))
81
+ throw new DecodeError(`${what} is not a finite number`);
82
+ return v;
83
+ }
84
+ function finiteF64le(r, what) {
85
+ const v = r.f64le();
86
+ if (!Number.isFinite(v))
87
+ throw new DecodeError(`${what} is not a finite number`);
88
+ return v;
89
+ }
90
+ /**
91
+ * A sample rate is only usable for converting sample offsets to seconds if it
92
+ * is a real rate. Anything outside this window is refused rather than used to
93
+ * manufacture a plausible-looking duration.
94
+ */
95
+ function usableRate(rate) {
96
+ return typeof rate === "number" && Number.isFinite(rate) && rate >= 8000 && rate <= 768000
97
+ ? rate
98
+ : null;
99
+ }
100
+ function seconds(sampleOffset, rate) {
101
+ if (rate === null)
102
+ return null;
103
+ const v = Math.round((sampleOffset / rate) * 1000) / 1000;
104
+ // Engine stores some main cues a hair *before* zero (-1.45e-11 samples on
105
+ // one real track), which rounds to negative zero. JSON.stringify turns -0
106
+ // into 0, so leaving it would make the value the server sends over the
107
+ // wire differ from the value it holds — and would make any equality check
108
+ // against a recorded fixture fail for a difference nobody can observe.
109
+ return v === 0 ? 0 : v;
110
+ }
111
+ /** Reads a count field that a fixed-size allocation or loop depends on. */
112
+ function boundedCount(r, littleEndian, cap, what) {
113
+ const n = littleEndian ? r.i64le() : r.i64();
114
+ if (n < 0 || n > cap)
115
+ throw new DecodeError(`unsupported ${what} ${n}`);
116
+ return n;
117
+ }
118
+ function guard(buf, layout, frame, body) {
119
+ if (!buf || buf.length === 0)
120
+ return { layout, status: "empty" };
121
+ try {
122
+ // Parsed in full (the buffer has to be walked to be validated at all),
123
+ // returned bounded.
124
+ const { items, ...extra } = body(new Reader(frame === "qcompress" ? qUncompress(buf) : buf));
125
+ return {
126
+ layout,
127
+ status: "ok",
128
+ items: items.slice(0, MAX_RETURNED),
129
+ total: items.length,
130
+ truncated: items.length > MAX_RETURNED,
131
+ ...extra,
132
+ };
133
+ }
134
+ catch (e) {
135
+ const detail = e.message;
136
+ return e instanceof DecodeError && /unsupported|signature/i.test(detail)
137
+ ? { layout, status: "unsupported", detail, bytes: buf.length }
138
+ : { layout, status: "corrupt", detail };
139
+ }
140
+ }
141
+ /**
142
+ * quickCues — qCompress frame, big-endian, 129 bytes inflated for a track
143
+ * whose cue labels are all empty:
144
+ *
145
+ * int64 slot count (8 bytes, always 8)
146
+ * per slot: uint8 label length, label bytes,
147
+ * float64 sample offset (-1.0 = slot unused),
148
+ * 4 colour bytes (13 bytes with an empty label)
149
+ * float64 main cue, uint8 main-cue-adjusted flag, float64 default main cue
150
+ * (17 bytes)
151
+ *
152
+ * Evidence. 8 + 8×13 + 17 = 129, the size of 278 of the 281 real blobs, and
153
+ * every one parsed to exactly its last byte. The remaining three inflate to
154
+ * 134: 5 bytes longer because one slot carries the 5-character label
155
+ * "Cue 8", which is only consistent with a *variable*-length slot — a
156
+ * fixed-stride layout cannot produce two sizes. The -1.0 sentinel appears at
157
+ * the offset this layout predicts in all 2245 unused slots, so the 13-byte
158
+ * stride is confirmed 2245 times over, not once. The three populated slots
159
+ * decode to 244.94 s of a 300 s track and 0.05 s of a 369 s track — inside
160
+ * the track, which a wrong offset would not be. The main-cue triple's first
161
+ * double distributes as: 122 blobs at the -1.0 sentinel, 51 at exactly 0,
162
+ * 105 at a positive offset inside the track, and 3 slightly negative — two
163
+ * at -1.455e-11 samples and one at -598.8 samples (-0.0136 s), all three
164
+ * within a beat of zero rather than anywhere random, which is what a
165
+ * misaligned read would produce. Its two doubles differ from each other on
166
+ * 25 tracks, which they could not if the layout had merged one field with
167
+ * its neighbour.
168
+ *
169
+ * Not verified: which of the four colour bytes is which channel. Reading
170
+ * them as (alpha, red, green, blue) makes the one slot carrying an unedited
171
+ * Engine default agree with djinterop's `pad_8` constant {0x15, 0x8E, 0xE2}
172
+ * on red and blue but not green, and the alternative reading gives a cue
173
+ * marker 12% opaque, so alpha-first is likely — but "likely" is not
174
+ * measured, and two colours from two tracks cannot settle it. The bytes are
175
+ * therefore reported as stored, as one big-endian u32, with no channel
176
+ * claim attached.
177
+ */
178
+ export function decodeCues(buf, sampleRate) {
179
+ const rate = usableRate(sampleRate);
180
+ return guard(buf, LAYOUT_VERIFIED, "qcompress", (r) => {
181
+ const slots = boundedCount(r, false, MAX_ITEMS, `cue slot count`);
182
+ const items = [];
183
+ for (let i = 0; i < slots; i++) {
184
+ // A cue label's length is a single byte, so no cap of ours can be
185
+ // tighter than the 255 it can express. The defence against a length
186
+ // that overruns the blob is Reader's bounds check inside utf8(), which
187
+ // refuses rather than silently returning a short label.
188
+ const label = r.utf8(r.u8());
189
+ const position = finiteF64(r, "cue position");
190
+ const colour = r.u32();
191
+ // An unused slot is not a cue. Reporting eight slots per track, six of
192
+ // them at sample -1, would put fabricated cue points in front of a
193
+ // model that has no way to tell them from real ones.
194
+ if (position === UNSET)
195
+ continue;
196
+ items.push({
197
+ index: i,
198
+ label,
199
+ position_samples: position,
200
+ position_seconds: seconds(position, rate),
201
+ colour,
202
+ });
203
+ }
204
+ const main = finiteF64(r, "main cue");
205
+ const isAdjusted = r.u8() !== 0;
206
+ const mainDefault = finiteF64(r, "default main cue");
207
+ return {
208
+ items,
209
+ slots,
210
+ main_cue: {
211
+ position_samples: main === UNSET ? null : main,
212
+ position_seconds: main === UNSET ? null : seconds(main, rate),
213
+ default_samples: mainDefault === UNSET ? null : mainDefault,
214
+ is_adjusted: isAdjusted,
215
+ },
216
+ };
217
+ });
218
+ }
219
+ /**
220
+ * Whether this track actually has a hot cue set — the question "which tracks
221
+ * still need cue points?" is really asking.
222
+ *
223
+ * It exists because the cheap SQL answer is not an answer at all. Engine
224
+ * writes a full eight-slot `quickCues` blob to every analysed track whether
225
+ * or not a pad is used, so `length(quickCues) > 0` is true for all 281 blobs
226
+ * in the reference library while exactly 3 of them (two tracks, one of which
227
+ * is exported to a second library) hold a cue. A check that structurally
228
+ * cannot report a problem is worse than no check: it answers "none of your
229
+ * tracks need cue points" for a library where 255 of 257 do.
230
+ *
231
+ * The main cue is deliberately excluded. It is set on 159 of the 281 blobs,
232
+ * including all 71 tracks the library records as played and 88 that it does
233
+ * not — Engine writes it as a playback start marker, not as something a DJ
234
+ * placed. Counting it would make this flag answer a third question, closer
235
+ * to "has this been loaded on a deck" than to "has a cue been set".
236
+ *
237
+ * A blob that fails to decode answers `false`: an undecodable blob is not
238
+ * evidence that a cue exists, and the direction that errs toward flagging a
239
+ * track for a human to look at is the safe one for an audit.
240
+ */
241
+ export function hasCueSet(buf) {
242
+ const cues = decodeCues(buf, null);
243
+ return cues.status === "ok" && cues.items.length > 0;
244
+ }
245
+ /**
246
+ * loops — 192 raw bytes, uncompressed and unframed, little-endian:
247
+ *
248
+ * int64 slot count (8 bytes, always 8)
249
+ * per slot: uint8 label length, label bytes,
250
+ * float64 start, float64 end (-1.0 = slot unused),
251
+ * uint8 start-set, uint8 end-set, 4 colour bytes
252
+ * (23 bytes with an empty label)
253
+ *
254
+ * Evidence for the framing and the slot grid: 8 + 8×23 = 192, the size of
255
+ * all 281 real blobs, every one of which parsed to exactly its last byte;
256
+ * the little-endian count reads as 8, and the little-endian -1.0 sentinel
257
+ * (`000000000000f0bf`) appears at the offsets this layout predicts in all
258
+ * 2248 slots. Reading the count big-endian gives 134217728, which is what
259
+ * made every real track decode as `unsupported` before.
260
+ *
261
+ * This layout keeps `layout: "unverified"`. Not one of the 2248 slots is
262
+ * populated — this library has no saved loops — so while the slot grid is
263
+ * pinned down by 2248 sentinels, the six bytes after each slot's two doubles
264
+ * are zero everywhere, and nothing here distinguishes start/end from
265
+ * end/start, or fixes the order of the flag and colour bytes. A populated
266
+ * loop is the one thing the available data cannot exercise, so it is not
267
+ * claimed as verified.
268
+ */
269
+ export function decodeLoops(buf, sampleRate) {
270
+ const rate = usableRate(sampleRate);
271
+ return guard(buf, LAYOUT_UNVERIFIED, "raw", (r) => {
272
+ const slots = boundedCount(r, true, MAX_ITEMS, `loop slot count`);
273
+ const items = [];
274
+ for (let i = 0; i < slots; i++) {
275
+ const label = r.utf8(r.u8()); // see decodeCues on why there is no cap here
276
+ const start = finiteF64le(r, "loop start");
277
+ const end = finiteF64le(r, "loop end");
278
+ r.skip(6); // start-set flag, end-set flag, four colour bytes
279
+ if (start === UNSET && end === UNSET)
280
+ continue;
281
+ items.push({
282
+ index: i,
283
+ label,
284
+ start_samples: start,
285
+ end_samples: end,
286
+ start_seconds: seconds(start, rate),
287
+ end_seconds: seconds(end, rate),
288
+ });
289
+ }
290
+ return { items, slots };
291
+ });
292
+ }
293
+ /**
294
+ * beatData — qCompress frame, 138 bytes inflated on every track measured,
295
+ * and mixed-endian:
296
+ *
297
+ * float64 BE sample rate
298
+ * float64 BE sample count
299
+ * uint8 beat data present
300
+ * int64 BE default-grid marker count, then that many markers
301
+ * int64 BE adjusted-grid marker count, then that many markers
302
+ * 9 trailing bytes (zero on every track measured)
303
+ *
304
+ * A marker is 24 bytes and little-endian: float64 sample offset, int64 beat
305
+ * number, int32 beats until the next marker, int32 unknown.
306
+ *
307
+ * Evidence. 17 + 8 + 2×24 + 8 + 2×24 + 9 = 138 for all 281. The two leading
308
+ * doubles read big-endian as 44100 (276 tracks) or 48000 (5); read the other
309
+ * way they are denormals. sample count ÷ sample rate equals `Track.length`
310
+ * to within a second for all 281 — a check on both doubles at once that no
311
+ * other offset or endianness passes. Within a grid, the beat numbers of
312
+ * consecutive markers differ by exactly the preceding marker's "beats until
313
+ * next" field on all 281, which pins the marker stride and three of its four
314
+ * fields simultaneously. The tempo implied by the adjusted grid's first and
315
+ * last anchor matches `Track.bpmAnalyzed` to within 0.5 BPM on all 281 —
316
+ * across tempos from 102 to 170 and durations from 92 s to 602 s, a
317
+ * prediction a wrong offset or a wrong endianness has no way to satisfy.
318
+ *
319
+ * `items` holds the *adjusted* grid, which is what Engine plays. That choice
320
+ * is itself measured, not stylistic: the two grids are byte-identical on 251
321
+ * tracks and differ on 30, and on seven of those the default grid runs at
322
+ * exactly half `bpmAnalyzed` (85.0000 against 170, 80.0000 against 160)
323
+ * while the adjusted grid matches it. The trailing int32 of each marker is
324
+ * read and discarded:
325
+ * it holds 0-12 on first markers and 1 or 2 on last ones, with two tracks
326
+ * carrying values that look like float bit patterns, and nothing in the
327
+ * library explains it. Reporting a field nobody can interpret would be
328
+ * padding a model's context with noise.
329
+ */
330
+ export function decodeBeatgrid(buf) {
331
+ return guard(buf, LAYOUT_VERIFIED, "qcompress", (r) => {
332
+ const sampleRate = finiteF64(r, "sample rate");
333
+ const rate = usableRate(sampleRate);
334
+ if (rate === null)
335
+ throw new DecodeError(`unsupported sample rate ${sampleRate}`);
336
+ const sampleCount = finiteF64(r, "sample count");
337
+ if (sampleCount < 0)
338
+ throw new DecodeError(`sample count is negative: ${sampleCount}`);
339
+ r.u8(); // beat data present; 1 on every analysed track measured
340
+ const readGrid = (which) => {
341
+ const count = boundedCount(r, false, MAX_ANCHORS, `${which} beatgrid anchor count`);
342
+ const anchors = [];
343
+ for (let i = 0; i < count; i++) {
344
+ const sample = finiteF64le(r, "beat anchor sample");
345
+ const beat = r.i64le();
346
+ r.i32le(); // beats until the next marker; re-derivable from beat numbers
347
+ r.i32le(); // unknown, see the note above
348
+ anchors.push({ sample, beat, seconds: seconds(sample, rate) });
349
+ }
350
+ return anchors;
351
+ };
352
+ readGrid("default");
353
+ const items = readGrid("adjusted");
354
+ let bpm = null;
355
+ if (items.length >= 2) {
356
+ const first = items[0];
357
+ const last = items[items.length - 1];
358
+ const spanSeconds = (last.sample - first.sample) / rate;
359
+ // A zero or backwards span would divide into Infinity or a negative
360
+ // tempo; either is a decode failure dressed as a number.
361
+ if (spanSeconds > 0) {
362
+ const value = ((last.beat - first.beat) / spanSeconds) * 60;
363
+ if (Number.isFinite(value) && value > 0)
364
+ bpm = Math.round(value * 1000) / 1000;
365
+ }
366
+ }
367
+ return {
368
+ items,
369
+ sample_rate: sampleRate,
370
+ sample_count: sampleCount,
371
+ duration_seconds: Math.round((sampleCount / rate) * 1000) / 1000,
372
+ bpm,
373
+ grid: "adjusted",
374
+ };
375
+ });
376
+ }
377
+ /**
378
+ * overviewWaveFormData — qCompress frame, big-endian header, 3099 bytes
379
+ * inflated on every track measured:
380
+ *
381
+ * int64 BE number of waveform points
382
+ * int64 BE the same number again
383
+ * float64 BE audio samples per point
384
+ * three bytes per point (three band levels)
385
+ * three trailing bytes: the maximum of each band over the whole track
386
+ *
387
+ * Evidence. Both counts read 1024 on all 281 blobs, and 24 + 3×1024 + 3 =
388
+ * 3099 accounts for every byte. The overview is a fixed 1024 points
389
+ * regardless of track length, so it is the *spacing* that scales with
390
+ * duration, not the size: samples-per-point × 1024 equals the sample count
391
+ * in `beatData` on all 281 — for a 345-second track that is 14858.0 × 1024 =
392
+ * 15214592 samples, the same value beatData carries. The three trailing
393
+ * bytes equal the per-band maximum computed over the 1024 points on all 281,
394
+ * a prediction with 255³ ways to fail per track that failed on none. That
395
+ * last check is what makes the three-bytes-per-point *stride* measured
396
+ * rather than assumed, and it is why this field carries `layout: "verified"`
397
+ * like cues and the beatgrid.
398
+ *
399
+ * Not verified: that the three bytes per point are the low, mid and high
400
+ * bands, in that order. Three parallel level channels is what the byte
401
+ * evidence shows; naming them is a reading of Engine's own display, and
402
+ * nothing in the library distinguishes one ordering from another. Nothing
403
+ * downstream depends on it — `profile` takes the loudest of the three,
404
+ * which is order-independent — so the naming is kept out of the response
405
+ * rather than asserted in it.
406
+ *
407
+ * The raw waveform is never returned to the model; it is reduced to a coarse
408
+ * per-bucket profile (loudest band value in the bucket, normalised to 0..1).
409
+ * That bucketing now runs over the waveform points rather than over the
410
+ * decompressed bytes, which previously mixed the 24-byte header and the
411
+ * trailing maxima into the first and last buckets.
412
+ *
413
+ * `duration_seconds` still comes from Track.length rather than from the
414
+ * blob: the overview carries a sample *count* but no sample rate, so a
415
+ * duration derived from it alone would be a guess.
416
+ */
417
+ export function summariseWaveform(buf, buckets = 32, durationSeconds = null) {
418
+ if (!buf || buf.length === 0)
419
+ return { layout: LAYOUT_VERIFIED, status: "empty" };
420
+ try {
421
+ const data = qUncompress(buf);
422
+ if (data.length === 0)
423
+ return { layout: LAYOUT_VERIFIED, status: "empty" };
424
+ const r = new Reader(data);
425
+ const entries = boundedCount(r, false, MAX_WAVEFORM_ENTRIES, "waveform point count");
426
+ const entriesAgain = boundedCount(r, false, MAX_WAVEFORM_ENTRIES, "waveform point count");
427
+ if (entries !== entriesAgain) {
428
+ throw new DecodeError(`waveform point counts disagree: ${entries} and ${entriesAgain}`);
429
+ }
430
+ const samplesPerEntry = finiteF64(r, "waveform samples per point");
431
+ // bytes(): bounds-checked, so a count larger than the blob is refused
432
+ // here rather than producing a silently short waveform.
433
+ const points = r.bytes(entries * 3);
434
+ const size = Math.max(1, Math.ceil(entries / Math.max(1, buckets)));
435
+ const profile = [];
436
+ for (let i = 0; i < entries; i += size) {
437
+ let peak = 0;
438
+ for (let j = i; j < Math.min(i + size, entries); j++) {
439
+ const at = j * 3;
440
+ peak = Math.max(peak, points[at], points[at + 1], points[at + 2]);
441
+ }
442
+ profile.push(Math.round((peak / 255) * 100) / 100);
443
+ }
444
+ return {
445
+ layout: LAYOUT_VERIFIED,
446
+ status: "ok",
447
+ peaks: profile.length,
448
+ entries,
449
+ samples_per_entry: samplesPerEntry,
450
+ bytes: data.length,
451
+ duration_seconds: durationSeconds,
452
+ profile,
453
+ };
454
+ }
455
+ catch (e) {
456
+ const detail = e.message;
457
+ return e instanceof DecodeError && /unsupported|signature/i.test(detail)
458
+ ? { layout: LAYOUT_VERIFIED, status: "unsupported", detail, bytes: buf.length }
459
+ : { layout: LAYOUT_VERIFIED, status: "corrupt", detail };
460
+ }
461
+ }
462
+ /**
463
+ * Decodes every PerformanceData field independently. A failure in one field
464
+ * (e.g. a corrupt beatgrid) never prevents the others from decoding — each
465
+ * carries its own status rather than the call as a whole throwing or failing.
466
+ *
467
+ * The one dependency between fields is the sample rate, which only beatData
468
+ * carries and which cue and loop offsets need to become seconds. It is
469
+ * passed in as a value, so a beatData that fails to decode costs the cues
470
+ * their `position_seconds` and nothing else: `position_samples` is still
471
+ * reported, and the cue list still decodes.
472
+ */
473
+ export function decodePerformance(row) {
474
+ const beatgrid = decodeBeatgrid(row.beatData);
475
+ const sampleRate = beatgrid.status === "ok" ? beatgrid.sample_rate : null;
476
+ return {
477
+ sample_rate: sampleRate,
478
+ cues: decodeCues(row.quickCues, sampleRate),
479
+ loops: decodeLoops(row.loops, sampleRate),
480
+ beatgrid,
481
+ waveform_summary: summariseWaveform(row.overviewWaveFormData, 32, row.durationSeconds ?? null),
482
+ };
483
+ }
@@ -0,0 +1,44 @@
1
+ export declare class DecodeError extends Error {
2
+ }
3
+ /**
4
+ * Qt's qCompress framing: a 4-byte big-endian uncompressed length followed by
5
+ * a raw zlib stream. Engine stores its performance blobs this way.
6
+ *
7
+ * Inflation is bounded *before* it runs, not checked afterwards. This decode
8
+ * happens in the MCP server process, not in the killable query child, so an
9
+ * unbounded inflateSync turns one crafted blob into an out-of-memory kill of
10
+ * the whole server -- the exact failure the process split exists to prevent.
11
+ * "It is the user's own file" does not hold: defaultRoots() scans /Volumes,
12
+ * so a USB stick prepared by someone else is an ordinary input.
13
+ */
14
+ export declare function qUncompress(buf: Buffer): Buffer;
15
+ /**
16
+ * Sequential reader that fails loudly rather than reading past the end.
17
+ *
18
+ * Engine's PerformanceData is not uniformly big-endian, which is why both
19
+ * widths are offered here rather than one being "the" reader. Measured on a
20
+ * real library (257 tracks on a USB export plus 24 in the local history
21
+ * database): `quickCues`, `beatData` and `overviewWaveFormData` store their
22
+ * scalars big-endian, the whole `loops` blob is little-endian, and inside
23
+ * `beatData` the two grid *counts* are big-endian int64 while the marker
24
+ * structs that follow them are little-endian. A reader that assumed one
25
+ * order would decode roughly half of the real bytes into nonsense.
26
+ */
27
+ export declare class Reader {
28
+ #private;
29
+ private readonly buf;
30
+ constructor(buf: Buffer);
31
+ get offset(): number;
32
+ get remaining(): number;
33
+ u32(): number;
34
+ u8(): number;
35
+ f64(): number;
36
+ /** Little-endian float64 — the `loops` blob and `beatData`'s grid markers. */
37
+ f64le(): number;
38
+ i64(): number;
39
+ i64le(): number;
40
+ i32le(): number;
41
+ skip(n: number): void;
42
+ bytes(n: number): Buffer;
43
+ utf8(n: number): string;
44
+ }
@@ -0,0 +1,146 @@
1
+ // src/blobs/qcompress.ts
2
+ import { inflateSync } from "node:zlib";
3
+ export class DecodeError extends Error {
4
+ }
5
+ /**
6
+ * Hard ceiling on a single decompressed blob, independent of what the frame
7
+ * header claims. The declared length is four attacker-controlled bytes, so
8
+ * bounding inflation by it alone still permits a ~4 GiB claim backed by a
9
+ * few kilobytes of zeros. Engine's largest PerformanceData column
10
+ * (overviewWaveFormData) is on the order of tens of kilobytes, so 32 MiB is
11
+ * far above anything real and far below anything that hurts.
12
+ */
13
+ const MAX_UNCOMPRESSED = 32 * 1024 * 1024;
14
+ /**
15
+ * Qt's qCompress framing: a 4-byte big-endian uncompressed length followed by
16
+ * a raw zlib stream. Engine stores its performance blobs this way.
17
+ *
18
+ * Inflation is bounded *before* it runs, not checked afterwards. This decode
19
+ * happens in the MCP server process, not in the killable query child, so an
20
+ * unbounded inflateSync turns one crafted blob into an out-of-memory kill of
21
+ * the whole server -- the exact failure the process split exists to prevent.
22
+ * "It is the user's own file" does not hold: defaultRoots() scans /Volumes,
23
+ * so a USB stick prepared by someone else is an ordinary input.
24
+ */
25
+ export function qUncompress(buf) {
26
+ if (buf.length < 5)
27
+ throw new DecodeError(`frame too short: ${buf.length} bytes`);
28
+ const expected = buf.readUInt32BE(0);
29
+ if (expected > MAX_UNCOMPRESSED) {
30
+ throw new DecodeError(`unsupported uncompressed length ${expected}`);
31
+ }
32
+ let out;
33
+ try {
34
+ // maxOutputLength stops zlib at the declared size instead of letting it
35
+ // allocate whatever the stream expands to; a payload larger than the
36
+ // header claims aborts here rather than after the fact.
37
+ out = inflateSync(buf.subarray(4), { maxOutputLength: Math.max(expected, 1) });
38
+ }
39
+ catch (e) {
40
+ throw new DecodeError(`zlib: ${e.message}`);
41
+ }
42
+ if (out.length !== expected) {
43
+ throw new DecodeError(`length mismatch: header says ${expected}, inflated ${out.length}`);
44
+ }
45
+ return out;
46
+ }
47
+ /**
48
+ * Sequential reader that fails loudly rather than reading past the end.
49
+ *
50
+ * Engine's PerformanceData is not uniformly big-endian, which is why both
51
+ * widths are offered here rather than one being "the" reader. Measured on a
52
+ * real library (257 tracks on a USB export plus 24 in the local history
53
+ * database): `quickCues`, `beatData` and `overviewWaveFormData` store their
54
+ * scalars big-endian, the whole `loops` blob is little-endian, and inside
55
+ * `beatData` the two grid *counts* are big-endian int64 while the marker
56
+ * structs that follow them are little-endian. A reader that assumed one
57
+ * order would decode roughly half of the real bytes into nonsense.
58
+ */
59
+ export class Reader {
60
+ buf;
61
+ #off = 0;
62
+ constructor(buf) {
63
+ this.buf = buf;
64
+ }
65
+ get offset() {
66
+ return this.#off;
67
+ }
68
+ get remaining() {
69
+ return this.buf.length - this.#off;
70
+ }
71
+ #need(n) {
72
+ // n is derived from blob content in the variable-length cases, so a
73
+ // negative or non-integer n must not be allowed to slip past as a
74
+ // trivially satisfied bound.
75
+ if (!Number.isSafeInteger(n) || n < 0)
76
+ throw new DecodeError(`bad read length ${n}`);
77
+ if (this.remaining < n)
78
+ throw new DecodeError(`need ${n} bytes, ${this.remaining} left`);
79
+ }
80
+ u32() {
81
+ this.#need(4);
82
+ const v = this.buf.readUInt32BE(this.#off);
83
+ this.#off += 4;
84
+ return v;
85
+ }
86
+ u8() {
87
+ this.#need(1);
88
+ return this.buf.readUInt8(this.#off++);
89
+ }
90
+ f64() {
91
+ this.#need(8);
92
+ const v = this.buf.readDoubleBE(this.#off);
93
+ this.#off += 8;
94
+ return v;
95
+ }
96
+ /** Little-endian float64 — the `loops` blob and `beatData`'s grid markers. */
97
+ f64le() {
98
+ this.#need(8);
99
+ const v = this.buf.readDoubleLE(this.#off);
100
+ this.#off += 8;
101
+ return v;
102
+ }
103
+ /**
104
+ * Signed 64-bit integer, returned as a Number. Every int64 in these blobs
105
+ * is a count or a beat index, all far inside the safe-integer range; a
106
+ * value outside it is corruption, and is refused here rather than being
107
+ * silently rounded into a plausible-looking count.
108
+ */
109
+ #i64(v) {
110
+ const n = Number(v);
111
+ if (!Number.isSafeInteger(n))
112
+ throw new DecodeError(`int64 out of safe range: ${v}`);
113
+ return n;
114
+ }
115
+ i64() {
116
+ this.#need(8);
117
+ const v = this.#i64(this.buf.readBigInt64BE(this.#off));
118
+ this.#off += 8;
119
+ return v;
120
+ }
121
+ i64le() {
122
+ this.#need(8);
123
+ const v = this.#i64(this.buf.readBigInt64LE(this.#off));
124
+ this.#off += 8;
125
+ return v;
126
+ }
127
+ i32le() {
128
+ this.#need(4);
129
+ const v = this.buf.readInt32LE(this.#off);
130
+ this.#off += 4;
131
+ return v;
132
+ }
133
+ skip(n) {
134
+ this.#need(n);
135
+ this.#off += n;
136
+ }
137
+ bytes(n) {
138
+ this.#need(n);
139
+ const v = this.buf.subarray(this.#off, this.#off + n);
140
+ this.#off += n;
141
+ return v;
142
+ }
143
+ utf8(n) {
144
+ return this.bytes(n).toString("utf8");
145
+ }
146
+ }