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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/blobs/index.d.ts +337 -0
- package/dist/blobs/index.js +483 -0
- package/dist/blobs/qcompress.d.ts +44 -0
- package/dist/blobs/qcompress.js +146 -0
- package/dist/discovery.d.ts +36 -0
- package/dist/discovery.js +111 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +49 -0
- package/dist/guard.d.ts +31 -0
- package/dist/guard.js +236 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/library-select.d.ts +63 -0
- package/dist/library-select.js +97 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +34 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +20 -0
- package/dist/proc/query-client.d.ts +36 -0
- package/dist/proc/query-client.js +249 -0
- package/dist/proc/query-worker.d.ts +1 -0
- package/dist/proc/query-worker.js +72 -0
- package/dist/semantics.d.ts +43 -0
- package/dist/semantics.js +95 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +439 -0
- package/dist/sidecar/build.d.ts +19 -0
- package/dist/sidecar/build.js +85 -0
- package/dist/sidecar/schema.d.ts +25 -0
- package/dist/sidecar/schema.js +36 -0
- package/dist/store/connections.d.ts +28 -0
- package/dist/store/connections.js +116 -0
- package/dist/store/index-manager.d.ts +29 -0
- package/dist/store/index-manager.js +187 -0
- package/dist/tools/audit.d.ts +15 -0
- package/dist/tools/audit.js +148 -0
- package/dist/tools/libraries.d.ts +40 -0
- package/dist/tools/libraries.js +30 -0
- package/dist/tools/performance.d.ts +15 -0
- package/dist/tools/performance.js +47 -0
- package/dist/tools/refresh.d.ts +8 -0
- package/dist/tools/refresh.js +3 -0
- package/dist/tools/search.d.ts +60 -0
- package/dist/tools/search.js +328 -0
- package/dist/tools/sql.d.ts +14 -0
- package/dist/tools/sql.js +21 -0
- package/dist/tools/tracks.d.ts +12 -0
- package/dist/tools/tracks.js +49 -0
- 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
|
+
}
|