alphatheta-connect 0.23.2 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -631,8 +631,10 @@ exports.MAX_CDJ_DEVICE_ID = exports.MIN_CDJ_DEVICE_ID = exports.VIRTUAL_CDJ_FIRM
631
631
  /**
632
632
  * The default virtual CDJ ID to use.
633
633
  *
634
- * This particular ID is out of the 1-6 range, thus will not be able to request
635
- * metadata via the remotedb for CDJs.
634
+ * Out of the 1-6 player range, so it can never collide with a real CDJ.
635
+ * Remotedb metadata still works from here: the 1-6 restriction applies to the
636
+ * device-ID byte inside remotedb messages, which RemoteDatabase picks
637
+ * per-connection independently of the announced ID.
636
638
  */
637
639
  exports.DEFAULT_VCDJ_ID = 0x07;
638
640
  /**
@@ -1266,25 +1268,24 @@ async function viaRemote(remote, opts) {
1266
1268
  return track;
1267
1269
  }
1268
1270
  async function viaLocal(local, device, opts) {
1269
- const { deviceId, trackSlot, trackId } = opts;
1271
+ const { deviceId, trackSlot, trackId, trackBPM } = opts;
1270
1272
  if (trackSlot !== types_1.MediaSlot.USB && trackSlot !== types_1.MediaSlot.SD) {
1271
1273
  throw new Error('Expected USB or SD slot for local database query');
1272
1274
  }
1273
- const adapter = await local.get(deviceId, trackSlot);
1274
- if (adapter === null) {
1275
- return { track: null, miss: 'no-database' };
1275
+ const lookup = await local.findTrack(deviceId, trackSlot, trackId, { trackBPM });
1276
+ if (lookup.adapter === null) {
1277
+ return { track: null, miss: 'no-database', switchedTo: null };
1276
1278
  }
1277
- const dbTrack = adapter.findTrack(trackId);
1278
- if (dbTrack === null) {
1279
- return { track: null, miss: 'track-absent' };
1279
+ if (lookup.track === null) {
1280
+ return { track: null, miss: 'track-absent', switchedTo: null };
1280
1281
  }
1281
- const anlz = await (0, rekordbox_1.loadAnlz)(dbTrack, 'DAT', (0, utils_1.anlzLoader)({ device, slot: trackSlot }));
1282
+ const anlz = await (0, rekordbox_1.loadAnlz)(lookup.track, 'DAT', (0, utils_1.anlzLoader)({ device, slot: trackSlot }));
1282
1283
  const track = {
1283
- ...dbTrack,
1284
+ ...lookup.track,
1284
1285
  beatGrid: anlz.beatGrid,
1285
1286
  waveformHd: null,
1286
1287
  };
1287
- return { track, miss: null };
1288
+ return { track, miss: null, switchedTo: lookup.switchedTo };
1288
1289
  }
1289
1290
 
1290
1291
 
@@ -1536,7 +1537,6 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
1536
1537
  };
1537
1538
  var _Database_instances, _Database_deviceManager, _Database_localDatabase, _Database_remoteDatabase, _Database_logger, _Database_getTrackLookupStrategy, _Database_getMediaLookupStrategy, _Database_metadataViaRemoteFallback;
1538
1539
  Object.defineProperty(exports, "__esModule", ({ value: true }));
1539
- const constants_1 = __webpack_require__(/*! src/constants */ "./src/constants.ts");
1540
1540
  const logger_1 = __webpack_require__(/*! src/logger */ "./src/logger.ts");
1541
1541
  const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
1542
1542
  const utils_1 = __webpack_require__(/*! src/utils */ "./src/utils/index.ts");
@@ -1609,6 +1609,7 @@ class Database {
1609
1609
  * Retrieve metadata for a track on a specific device slot.
1610
1610
  */
1611
1611
  async getMetadata(opts) {
1612
+ var _a;
1612
1613
  const { deviceId, trackType, trackSlot, span } = opts;
1613
1614
  const tx = span
1614
1615
  ? span.startChild({ op: 'dbGetMetadata' })
@@ -1616,7 +1617,7 @@ class Database {
1616
1617
  tx.setTag('deviceId', deviceId.toString());
1617
1618
  tx.setTag('trackType', (0, utils_1.getTrackTypeName)(trackType));
1618
1619
  tx.setTag('trackSlot', (0, utils_1.getSlotName)(trackSlot));
1619
- const callOpts = { ...opts, span: tx };
1620
+ const callOpts = { ...opts, trackBPM: (_a = opts.trackBPM) !== null && _a !== void 0 ? _a : null, span: tx };
1620
1621
  const device = await __classPrivateFieldGet(this, _Database_deviceManager, "f").getDeviceEnsured(deviceId);
1621
1622
  if (device === null) {
1622
1623
  return null;
@@ -1629,6 +1630,12 @@ class Database {
1629
1630
  if (strategy === LookupStrategy.Local) {
1630
1631
  const local = await GetMetadata.viaLocal(__classPrivateFieldGet(this, _Database_localDatabase, "f"), device, callOpts);
1631
1632
  track = local.track;
1633
+ if (local.switchedTo !== null) {
1634
+ __classPrivateFieldGet(this, _Database_logger, "f").info(`Device ${deviceId} ${(0, utils_1.getSlotName)(trackSlot)} is now read from the ` +
1635
+ `${local.switchedTo === 'pdb' ? 'legacy PDB' : 'OneLibrary'} database: ` +
1636
+ `it is the one that holds track ${opts.trackId} as the player reports it ` +
1637
+ `(NP3-399)`);
1638
+ }
1632
1639
  // A local miss used to end the lookup, so the DJ's track silently never
1633
1640
  // reached the overlay for the rest of the set (NP3-361). Say what was
1634
1641
  // missed, then give the CDJ's own database a chance to answer.
@@ -1836,20 +1843,14 @@ _Database_deviceManager = new WeakMap(), _Database_localDatabase = new WeakMap()
1836
1843
  /**
1837
1844
  * Second chance for a track the local database could not answer for.
1838
1845
  *
1839
- * CDJs only answer remote database queries from a host announcing a device
1840
- * ID in the 1-6 range, so this is unavailable whenever the virtual CDJ sits
1841
- * outside it (the package default is 7). That is a real configuration, not
1842
- * an error — log why the fallback was skipped rather than throwing, so the
1843
- * next report says which of the two happened.
1846
+ * Available regardless of the virtual CDJ's announced ID: CDJs restrict
1847
+ * remote database queries to a device-ID byte in the 1-6 range, but that
1848
+ * byte lives inside the remotedb messages and RemoteDatabase picks an
1849
+ * in-range one per connection — the announced ID never constrains this
1850
+ * lookup. A failure here is logged rather than thrown, so a field report
1851
+ * says what actually went wrong.
1844
1852
  */
1845
1853
  async function _Database_metadataViaRemoteFallback(opts) {
1846
- const hostId = __classPrivateFieldGet(this, _Database_remoteDatabase, "f").hostDevice.id;
1847
- if (hostId < constants_1.MIN_CDJ_DEVICE_ID || hostId > constants_1.MAX_CDJ_DEVICE_ID) {
1848
- __classPrivateFieldGet(this, _Database_logger, "f").warn(`No remote fallback for track ${opts.trackId}: this player is device ` +
1849
- `${hostId}, and CDJs only answer remote database queries from ` +
1850
- `devices ${constants_1.MIN_CDJ_DEVICE_ID}-${constants_1.MAX_CDJ_DEVICE_ID}`);
1851
- return null;
1852
- }
1853
1854
  try {
1854
1855
  const track = await GetMetadata.viaRemote(__classPrivateFieldGet(this, _Database_remoteDatabase, "f"), opts);
1855
1856
  if (track === null) {
@@ -2134,7 +2135,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
2134
2135
  return (mod && mod.__esModule) ? mod : { "default": mod };
2135
2136
  };
2136
2137
  Object.defineProperty(exports, "__esModule", ({ value: true }));
2137
- exports.noopLogger = exports.REMOTEDB_MAX_DEVICE_ID = exports.playerNumberCeiling = exports.pickAvailableDeviceId = exports.MIXER_PLAYER_CEILING = exports.MAX_DEVICE_ID = exports.DEFAULT_MIXER_PLAYER_CEILING = exports.OneLibraryAdapter = exports.fetchFile = exports.loadAnlz = exports.isMetadataExtractionSupported = exports.extractMetadataFromDevice = exports.extractFullMetadata = exports.PictureType = exports.isArtworkExtractionSupported = exports.extractArtworkFromDevice = exports.extractArtwork = exports.PositionEmitter = void 0;
2138
+ exports.noopLogger = exports.REMOTEDB_MAX_DEVICE_ID = exports.playerNumberCeiling = exports.pickRemoteDbQueryId = exports.pickAvailableDeviceId = exports.MIXER_PLAYER_CEILING = exports.MAX_DEVICE_ID = exports.DEFAULT_MIXER_PLAYER_CEILING = exports.OneLibraryAdapter = exports.fetchFile = exports.loadAnlz = exports.isMetadataExtractionSupported = exports.extractMetadataFromDevice = exports.extractFullMetadata = exports.PictureType = exports.isArtworkExtractionSupported = exports.extractArtworkFromDevice = exports.extractArtwork = exports.PositionEmitter = void 0;
2138
2139
  __exportStar(__webpack_require__(/*! ./entities */ "./src/entities.ts"), exports);
2139
2140
  __exportStar(__webpack_require__(/*! ./mixstatus */ "./src/mixstatus/index.ts"), exports);
2140
2141
  __exportStar(__webpack_require__(/*! ./network */ "./src/network.ts"), exports);
@@ -2162,6 +2163,7 @@ Object.defineProperty(exports, "DEFAULT_MIXER_PLAYER_CEILING", ({ enumerable: tr
2162
2163
  Object.defineProperty(exports, "MAX_DEVICE_ID", ({ enumerable: true, get: function () { return device_id_1.MAX_DEVICE_ID; } }));
2163
2164
  Object.defineProperty(exports, "MIXER_PLAYER_CEILING", ({ enumerable: true, get: function () { return device_id_1.MIXER_PLAYER_CEILING; } }));
2164
2165
  Object.defineProperty(exports, "pickAvailableDeviceId", ({ enumerable: true, get: function () { return device_id_1.pickAvailableDeviceId; } }));
2166
+ Object.defineProperty(exports, "pickRemoteDbQueryId", ({ enumerable: true, get: function () { return device_id_1.pickRemoteDbQueryId; } }));
2165
2167
  Object.defineProperty(exports, "playerNumberCeiling", ({ enumerable: true, get: function () { return device_id_1.playerNumberCeiling; } }));
2166
2168
  Object.defineProperty(exports, "REMOTEDB_MAX_DEVICE_ID", ({ enumerable: true, get: function () { return device_id_1.REMOTEDB_MAX_DEVICE_ID; } }));
2167
2169
  var logger_1 = __webpack_require__(/*! ./logger */ "./src/logger.ts");
@@ -2224,7 +2226,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
2224
2226
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
2225
2227
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
2226
2228
  };
2227
- var _LocalDatabase_hostDevice, _LocalDatabase_deviceManager, _LocalDatabase_statusEmitter, _LocalDatabase_emitter, _LocalDatabase_slotLocks, _LocalDatabase_dbs, _LocalDatabase_preference, _LocalDatabase_handleDeviceRemoved, _LocalDatabase_fetchFileWithFallback, _LocalDatabase_tryLoadOneLibrary, _LocalDatabase_loadPdbDatabase, _LocalDatabase_hydrateDatabase;
2229
+ var _LocalDatabase_instances, _LocalDatabase_hostDevice, _LocalDatabase_deviceManager, _LocalDatabase_statusEmitter, _LocalDatabase_emitter, _LocalDatabase_slotLocks, _LocalDatabase_dbs, _LocalDatabase_preference, _LocalDatabase_handleDeviceRemoved, _LocalDatabase_closeLoaded, _LocalDatabase_fetchFileWithFallback, _LocalDatabase_tryLoadOneLibrary, _LocalDatabase_loadPdbDatabase, _LocalDatabase_hydrateDatabase, _LocalDatabase_loadAlternate, _LocalDatabase_getItem;
2228
2230
  Object.defineProperty(exports, "__esModule", ({ value: true }));
2229
2231
  const async_mutex_1 = __webpack_require__(/*! async-mutex */ "async-mutex");
2230
2232
  const onelibrary_connect_1 = __webpack_require__(/*! onelibrary-connect */ "onelibrary-connect");
@@ -2240,6 +2242,18 @@ const utils_1 = __webpack_require__(/*! src/utils */ "./src/utils/index.ts");
2240
2242
  const Telemetry = __importStar(__webpack_require__(/*! src/utils/telemetry */ "./src/utils/telemetry.ts"));
2241
2243
  const orm_1 = __webpack_require__(/*! ./orm */ "./src/localdb/orm.ts");
2242
2244
  const rekordbox_1 = __webpack_require__(/*! ./rekordbox */ "./src/localdb/rekordbox.ts");
2245
+ /**
2246
+ * A row is only trusted when the player's reported BPM agrees with it. Either
2247
+ * side missing (unanalysed track, or a player that reports no BPM) is
2248
+ * inconclusive and counts as agreement.
2249
+ */
2250
+ const trackAgreesWithPlayer = (track, hint) => {
2251
+ const reported = hint.trackBPM;
2252
+ if (reported === null || reported === undefined || !track.tempo) {
2253
+ return true;
2254
+ }
2255
+ return Math.abs(track.tempo - reported) < 0.05;
2256
+ };
2243
2257
  /**
2244
2258
  * Compute the identifier for media device in a CDJ. This is used to determine
2245
2259
  * if we have already hydrated the device or not into our local database.
@@ -2266,6 +2280,7 @@ const getMediaId = (info) => {
2266
2280
  */
2267
2281
  class LocalDatabase {
2268
2282
  constructor(hostDevice, deviceManager, statusEmitter, preference = 'auto') {
2283
+ _LocalDatabase_instances.add(this);
2269
2284
  _LocalDatabase_hostDevice.set(this, void 0);
2270
2285
  _LocalDatabase_deviceManager.set(this, void 0);
2271
2286
  _LocalDatabase_statusEmitter.set(this, void 0);
@@ -2297,19 +2312,25 @@ class LocalDatabase {
2297
2312
  _LocalDatabase_handleDeviceRemoved.set(this, (device) => {
2298
2313
  const db = __classPrivateFieldGet(this, _LocalDatabase_dbs, "f").find(db => db.media.deviceId === device.id);
2299
2314
  if (db) {
2300
- db.adapter.close();
2301
- // Clean up temp file if it exists (OneLibrary databases)
2302
- if (db.tempFile) {
2303
- try {
2304
- fs.unlinkSync(db.tempFile);
2305
- }
2306
- catch {
2307
- // Ignore cleanup errors
2308
- }
2315
+ __classPrivateFieldGet(this, _LocalDatabase_closeLoaded, "f").call(this, db);
2316
+ if (db.alternate) {
2317
+ __classPrivateFieldGet(this, _LocalDatabase_closeLoaded, "f").call(this, db.alternate);
2309
2318
  }
2310
2319
  }
2311
2320
  __classPrivateFieldSet(this, _LocalDatabase_dbs, __classPrivateFieldGet(this, _LocalDatabase_dbs, "f").filter(db => db.media.deviceId !== device.id), "f");
2312
2321
  });
2322
+ _LocalDatabase_closeLoaded.set(this, (loaded) => {
2323
+ loaded.adapter.close();
2324
+ // Clean up temp file if it exists (OneLibrary databases)
2325
+ if (loaded.tempFile) {
2326
+ try {
2327
+ fs.unlinkSync(loaded.tempFile);
2328
+ }
2329
+ catch {
2330
+ // Ignore cleanup errors
2331
+ }
2332
+ }
2333
+ });
2313
2334
  /**
2314
2335
  * Helper to fetch a file from device, trying both dotted and non-dotted paths
2315
2336
  */
@@ -2416,11 +2437,54 @@ class LocalDatabase {
2416
2437
  }
2417
2438
  }
2418
2439
  __classPrivateFieldGet(this, _LocalDatabase_emitter, "f").emit('hydrationDone', { device, slot });
2419
- const db = { adapter, media, id: getMediaId(media), tempFile };
2440
+ const db = {
2441
+ adapter,
2442
+ media,
2443
+ device,
2444
+ slot,
2445
+ id: getMediaId(media),
2446
+ tempFile,
2447
+ alternateLock: new async_mutex_1.Mutex(),
2448
+ };
2420
2449
  __classPrivateFieldGet(this, _LocalDatabase_dbs, "f").push(db);
2421
2450
  tx.finish();
2422
2451
  return db;
2423
2452
  });
2453
+ /**
2454
+ * Loads the database format the slot is *not* currently served from, so a
2455
+ * lookup can be checked against it. Only meaningful under the 'auto'
2456
+ * preference: a forced format has no alternate. The result (or its absence)
2457
+ * is remembered so the media is never downloaded twice.
2458
+ */
2459
+ _LocalDatabase_loadAlternate.set(this, (db) => db.alternateLock.runExclusive(async () => {
2460
+ if (db.alternate !== undefined) {
2461
+ return db.alternate;
2462
+ }
2463
+ if (__classPrivateFieldGet(this, _LocalDatabase_preference, "f") !== 'auto') {
2464
+ db.alternate = null;
2465
+ return null;
2466
+ }
2467
+ const tx = Telemetry.startTransaction({ name: 'hydrateAlternateDatabase' });
2468
+ tx.setTag('slot', (0, utils_1.getSlotName)(db.media.slot));
2469
+ tx.setTag('activeDbType', db.adapter.type);
2470
+ try {
2471
+ if (db.adapter.type === 'oneLibrary') {
2472
+ const adapter = await __classPrivateFieldGet(this, _LocalDatabase_loadPdbDatabase, "f").call(this, db.device, db.slot, tx);
2473
+ db.alternate = { adapter };
2474
+ tx.setTag('dbType', 'pdb');
2475
+ }
2476
+ else {
2477
+ db.alternate = await __classPrivateFieldGet(this, _LocalDatabase_tryLoadOneLibrary, "f").call(this, db.device, db.slot, tx);
2478
+ tx.setTag('dbType', db.alternate ? 'oneLibrary' : 'none');
2479
+ }
2480
+ }
2481
+ catch {
2482
+ // The media simply has no such file (or it could not be read).
2483
+ db.alternate = null;
2484
+ }
2485
+ tx.finish();
2486
+ return db.alternate;
2487
+ }));
2424
2488
  __classPrivateFieldSet(this, _LocalDatabase_hostDevice, hostDevice, "f");
2425
2489
  __classPrivateFieldSet(this, _LocalDatabase_deviceManager, deviceManager, "f");
2426
2490
  __classPrivateFieldSet(this, _LocalDatabase_statusEmitter, statusEmitter, "f");
@@ -2445,6 +2509,45 @@ class LocalDatabase {
2445
2509
  disconnectForDevice(device) {
2446
2510
  __classPrivateFieldGet(this, _LocalDatabase_handleDeviceRemoved, "f").call(this, device);
2447
2511
  }
2512
+ /**
2513
+ * Looks a track up in the databases of a device slot, checking the answer
2514
+ * against what the player reports.
2515
+ *
2516
+ * A Device Library Plus export carries both `exportLibrary.db` and the
2517
+ * legacy `export.pdb`, and their track IDs are different number spaces. A
2518
+ * player that reads one while we loaded the other resolves most IDs to a
2519
+ * different track and some to nothing at all (NP3-399). So when the active
2520
+ * database has no such row, or its row's BPM is not the BPM the player is
2521
+ * showing, the other format is loaded and asked. If it agrees with the
2522
+ * player, the slot switches to it for every later lookup — artwork,
2523
+ * analysis and the next track all follow the database the player is using.
2524
+ *
2525
+ * When neither database can be confirmed, the active database's row (if any)
2526
+ * is returned as before.
2527
+ */
2528
+ async findTrack(deviceId, slot, trackId, hint = {}) {
2529
+ const db = await __classPrivateFieldGet(this, _LocalDatabase_instances, "m", _LocalDatabase_getItem).call(this, deviceId, slot);
2530
+ if (db === null) {
2531
+ return { adapter: null, track: null, switchedTo: null };
2532
+ }
2533
+ const active = db.adapter.findTrack(trackId);
2534
+ if (active !== null && trackAgreesWithPlayer(active, hint)) {
2535
+ return { adapter: db.adapter, track: active, switchedTo: null };
2536
+ }
2537
+ const alternate = await __classPrivateFieldGet(this, _LocalDatabase_loadAlternate, "f").call(this, db);
2538
+ if (alternate !== null) {
2539
+ const other = alternate.adapter.findTrack(trackId);
2540
+ if (other !== null && trackAgreesWithPlayer(other, hint)) {
2541
+ db.alternate = { adapter: db.adapter, tempFile: db.tempFile };
2542
+ db.adapter = alternate.adapter;
2543
+ db.tempFile = alternate.tempFile;
2544
+ return { adapter: db.adapter, track: other, switchedTo: db.adapter.type };
2545
+ }
2546
+ }
2547
+ return active === null
2548
+ ? { adapter: db.adapter, track: null, switchedTo: null }
2549
+ : { adapter: db.adapter, track: active, switchedTo: null };
2550
+ }
2448
2551
  /**
2449
2552
  * Gets the database adapter for the media metadata in the provided device slot.
2450
2553
  *
@@ -2455,43 +2558,8 @@ class LocalDatabase {
2455
2558
  */
2456
2559
  async get(deviceId, slot) {
2457
2560
  var _a;
2458
- const lockKey = `${deviceId}-${slot}`;
2459
- const lock = (_a = __classPrivateFieldGet(this, _LocalDatabase_slotLocks, "f").get(lockKey)) !== null && _a !== void 0 ? _a : __classPrivateFieldGet(this, _LocalDatabase_slotLocks, "f").set(lockKey, new async_mutex_1.Mutex()).get(lockKey);
2460
- const device = __classPrivateFieldGet(this, _LocalDatabase_deviceManager, "f").devices.get(deviceId);
2461
- if (device === undefined) {
2462
- return null;
2463
- }
2464
- if (device.type !== types_1.DeviceType.CDJ ||
2465
- device.id < constants_1.MIN_CDJ_DEVICE_ID ||
2466
- device.id > constants_1.MAX_CDJ_DEVICE_ID) {
2467
- return null;
2468
- }
2469
- let media;
2470
- try {
2471
- media = await __classPrivateFieldGet(this, _LocalDatabase_statusEmitter, "f").queryMediaSlot({
2472
- hostDevice: __classPrivateFieldGet(this, _LocalDatabase_hostDevice, "f"),
2473
- device,
2474
- slot,
2475
- });
2476
- }
2477
- catch {
2478
- // Timeout or other error - treat as no media
2479
- return null;
2480
- }
2481
- if (media.tracksType !== types_1.TrackType.RB) {
2482
- return null;
2483
- }
2484
- const id = getMediaId(media);
2485
- // Acquire a lock for this device slot that will not release until we've
2486
- // guaranteed the existence of the database.
2487
- const db = await lock.runExclusive(() => {
2488
- const cached = __classPrivateFieldGet(this, _LocalDatabase_dbs, "f").find(db => db.id === id);
2489
- if (cached) {
2490
- return cached;
2491
- }
2492
- return __classPrivateFieldGet(this, _LocalDatabase_hydrateDatabase, "f").call(this, device, slot, media);
2493
- });
2494
- return db.adapter;
2561
+ const db = await __classPrivateFieldGet(this, _LocalDatabase_instances, "m", _LocalDatabase_getItem).call(this, deviceId, slot);
2562
+ return (_a = db === null || db === void 0 ? void 0 : db.adapter) !== null && _a !== void 0 ? _a : null;
2495
2563
  }
2496
2564
  /**
2497
2565
  * Get the database type for an already-loaded device slot.
@@ -2517,7 +2585,50 @@ class LocalDatabase {
2517
2585
  await Promise.all(loaders);
2518
2586
  }
2519
2587
  }
2520
- _LocalDatabase_hostDevice = new WeakMap(), _LocalDatabase_deviceManager = new WeakMap(), _LocalDatabase_statusEmitter = new WeakMap(), _LocalDatabase_emitter = new WeakMap(), _LocalDatabase_slotLocks = new WeakMap(), _LocalDatabase_dbs = new WeakMap(), _LocalDatabase_preference = new WeakMap(), _LocalDatabase_handleDeviceRemoved = new WeakMap(), _LocalDatabase_fetchFileWithFallback = new WeakMap(), _LocalDatabase_tryLoadOneLibrary = new WeakMap(), _LocalDatabase_loadPdbDatabase = new WeakMap(), _LocalDatabase_hydrateDatabase = new WeakMap();
2588
+ _LocalDatabase_hostDevice = new WeakMap(), _LocalDatabase_deviceManager = new WeakMap(), _LocalDatabase_statusEmitter = new WeakMap(), _LocalDatabase_emitter = new WeakMap(), _LocalDatabase_slotLocks = new WeakMap(), _LocalDatabase_dbs = new WeakMap(), _LocalDatabase_preference = new WeakMap(), _LocalDatabase_handleDeviceRemoved = new WeakMap(), _LocalDatabase_closeLoaded = new WeakMap(), _LocalDatabase_fetchFileWithFallback = new WeakMap(), _LocalDatabase_tryLoadOneLibrary = new WeakMap(), _LocalDatabase_loadPdbDatabase = new WeakMap(), _LocalDatabase_hydrateDatabase = new WeakMap(), _LocalDatabase_loadAlternate = new WeakMap(), _LocalDatabase_instances = new WeakSet(), _LocalDatabase_getItem =
2589
+ /**
2590
+ * The loaded database entry for a device slot, hydrating it first if needed.
2591
+ */
2592
+ async function _LocalDatabase_getItem(deviceId, slot) {
2593
+ var _a;
2594
+ const lockKey = `${deviceId}-${slot}`;
2595
+ const lock = (_a = __classPrivateFieldGet(this, _LocalDatabase_slotLocks, "f").get(lockKey)) !== null && _a !== void 0 ? _a : __classPrivateFieldGet(this, _LocalDatabase_slotLocks, "f").set(lockKey, new async_mutex_1.Mutex()).get(lockKey);
2596
+ const device = __classPrivateFieldGet(this, _LocalDatabase_deviceManager, "f").devices.get(deviceId);
2597
+ if (device === undefined) {
2598
+ return null;
2599
+ }
2600
+ if (device.type !== types_1.DeviceType.CDJ ||
2601
+ device.id < constants_1.MIN_CDJ_DEVICE_ID ||
2602
+ device.id > constants_1.MAX_CDJ_DEVICE_ID) {
2603
+ return null;
2604
+ }
2605
+ let media;
2606
+ try {
2607
+ media = await __classPrivateFieldGet(this, _LocalDatabase_statusEmitter, "f").queryMediaSlot({
2608
+ hostDevice: __classPrivateFieldGet(this, _LocalDatabase_hostDevice, "f"),
2609
+ device,
2610
+ slot,
2611
+ });
2612
+ }
2613
+ catch {
2614
+ // Timeout or other error - treat as no media
2615
+ return null;
2616
+ }
2617
+ if (media.tracksType !== types_1.TrackType.RB) {
2618
+ return null;
2619
+ }
2620
+ const id = getMediaId(media);
2621
+ // Acquire a lock for this device slot that will not release until we've
2622
+ // guaranteed the existence of the database.
2623
+ const db = await lock.runExclusive(() => {
2624
+ const cached = __classPrivateFieldGet(this, _LocalDatabase_dbs, "f").find(db => db.id === id);
2625
+ if (cached) {
2626
+ return cached;
2627
+ }
2628
+ return __classPrivateFieldGet(this, _LocalDatabase_hydrateDatabase, "f").call(this, device, slot, media);
2629
+ });
2630
+ return db;
2631
+ };
2521
2632
  exports["default"] = LocalDatabase;
2522
2633
 
2523
2634
 
@@ -6793,7 +6904,7 @@ class MixstatusProcessor {
6793
6904
  // able to report it as live
6794
6905
  const startedAt = __classPrivateFieldGet(this, _MixstatusProcessor_lastStartTime, "f").get(deviceId);
6795
6906
  const requiredPlayTime = __classPrivateFieldGet(this, _MixstatusProcessor_config, "f").beatsUntilReported *
6796
- (0, utils_1.bpmToSeconds)(state.trackBPM, state.sliderPitch) *
6907
+ (0, utils_1.bpmToSeconds)(state.trackBPM, state.effectivePitch) *
6797
6908
  1000;
6798
6909
  if (__classPrivateFieldGet(this, _MixstatusProcessor_config, "f").mode === types_1.MixstatusMode.SmartTiming &&
6799
6910
  startedAt !== undefined &&
@@ -6804,7 +6915,7 @@ class MixstatusProcessor {
6804
6915
  // we can mark the track as truly stopped.
6805
6916
  const stoppedAt = __classPrivateFieldGet(this, _MixstatusProcessor_lastStoppedTimes, "f").get(deviceId);
6806
6917
  const requiredStopTime = __classPrivateFieldGet(this, _MixstatusProcessor_config, "f").allowedInterruptBeats *
6807
- (0, utils_1.bpmToSeconds)(state.trackBPM, state.sliderPitch) *
6918
+ (0, utils_1.bpmToSeconds)(state.trackBPM, state.effectivePitch) *
6808
6919
  1000;
6809
6920
  if (stoppedAt !== undefined && requiredStopTime <= Date.now() - stoppedAt) {
6810
6921
  __classPrivateFieldGet(this, _MixstatusProcessor_markPlayerStopped, "f").call(this, state);
@@ -7095,6 +7206,14 @@ class ProlinkNetwork {
7095
7206
  announcer = new virtualcdj_1.Announcer(vcdj, __classPrivateFieldGet(this, _ProlinkNetwork_announceSocket, "f"), this.deviceManager, __classPrivateFieldGet(this, _ProlinkNetwork_config, "f").iface, (_d = __classPrivateFieldGet(this, _ProlinkNetwork_config, "f").fullStartup) !== null && _d !== void 0 ? _d : false, (_e = __classPrivateFieldGet(this, _ProlinkNetwork_config, "f").announceToStagehand) !== null && _e !== void 0 ? _e : false, __classPrivateFieldGet(this, _ProlinkNetwork_logger, "f"));
7096
7207
  }
7097
7208
  announcer.start();
7209
+ // In Stagehand mode, unicast keep-alives to discovered players and the
7210
+ // mixer so they push live state to us (mixer 0x39/0x58, CDJ 0x69/waveforms).
7211
+ // Broadcast presence alone does not bootstrap that stream.
7212
+ let heartbeat = null;
7213
+ if (connectMethod === 'stagehand') {
7214
+ heartbeat = new virtualcdj_1.StagehandHeartbeat(vcdj, __classPrivateFieldGet(this, _ProlinkNetwork_statusSocket, "f"), __classPrivateFieldGet(this, _ProlinkNetwork_deviceManager, "f"), __classPrivateFieldGet(this, _ProlinkNetwork_logger, "f"));
7215
+ heartbeat.start();
7216
+ }
7098
7217
  // Create remote and local databases
7099
7218
  const remotedb = new remotedb_1.default(__classPrivateFieldGet(this, _ProlinkNetwork_deviceManager, "f"), vcdj);
7100
7219
  const localdb = new localdb_1.default(vcdj, __classPrivateFieldGet(this, _ProlinkNetwork_deviceManager, "f"), __classPrivateFieldGet(this, _ProlinkNetwork_statusEmitter, "f"), (_f = __classPrivateFieldGet(this, _ProlinkNetwork_config, "f").databasePreference) !== null && _f !== void 0 ? _f : 'auto');
@@ -7103,23 +7222,24 @@ class ProlinkNetwork {
7103
7222
  // Create controller service
7104
7223
  const control = new control_1.default(__classPrivateFieldGet(this, _ProlinkNetwork_beatSocket, "f"), vcdj);
7105
7224
  __classPrivateFieldSet(this, _ProlinkNetwork_state, types_1.NetworkState.Connected, "f");
7106
- __classPrivateFieldSet(this, _ProlinkNetwork_connection, { announcer, control, remotedb, localdb, database }, "f");
7225
+ __classPrivateFieldSet(this, _ProlinkNetwork_connection, { announcer, heartbeat, control, remotedb, localdb, database }, "f");
7107
7226
  tx.finish();
7108
7227
  }
7109
7228
  /**
7110
7229
  * Disconnect from the network
7111
7230
  */
7112
7231
  disconnect() {
7113
- var _a, _b, _c;
7232
+ var _a, _b, _c, _d, _e;
7114
7233
  if (__classPrivateFieldGet(this, _ProlinkNetwork_config, "f") === null) {
7115
7234
  throw new Error(connectErrorHelp);
7116
7235
  }
7117
7236
  // Stop announcing ourself
7118
7237
  (_a = __classPrivateFieldGet(this, _ProlinkNetwork_connection, "f")) === null || _a === void 0 ? void 0 : _a.announcer.stop();
7238
+ (_c = (_b = __classPrivateFieldGet(this, _ProlinkNetwork_connection, "f")) === null || _b === void 0 ? void 0 : _b.heartbeat) === null || _c === void 0 ? void 0 : _c.stop();
7119
7239
  // Disconnect devices from the remote and local databases
7120
7240
  for (const device of this.deviceManager.devices.values()) {
7121
- (_b = this.remotedb) === null || _b === void 0 ? void 0 : _b.disconnectFromDevice(device);
7122
- (_c = this.localdb) === null || _c === void 0 ? void 0 : _c.disconnectForDevice(device);
7241
+ (_d = this.remotedb) === null || _d === void 0 ? void 0 : _d.disconnectFromDevice(device);
7242
+ (_e = this.localdb) === null || _e === void 0 ? void 0 : _e.disconnectForDevice(device);
7123
7243
  }
7124
7244
  return this.close;
7125
7245
  }
@@ -10649,15 +10769,17 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
10649
10769
  var __importDefault = (this && this.__importDefault) || function (mod) {
10650
10770
  return (mod && mod.__esModule) ? mod : { "default": mod };
10651
10771
  };
10652
- var _Connection_socket, _Connection_txId, _Connection_lock, _QueryInterface_conn, _QueryInterface_hostDevice, _QueryInterface_lock, _RemoteDatabase_hostDevice, _RemoteDatabase_deviceManager, _RemoteDatabase_connections, _RemoteDatabase_deviceLocks;
10772
+ var _Connection_socket, _Connection_txId, _Connection_lock, _QueryInterface_conn, _QueryInterface_hostDevice, _QueryInterface_lock, _RemoteDatabase_instances, _RemoteDatabase_hostDevice, _RemoteDatabase_deviceManager, _RemoteDatabase_connections, _RemoteDatabase_deviceLocks, _RemoteDatabase_queryIds, _RemoteDatabase_pickQueryId, _RemoteDatabase_queryIdFor;
10653
10773
  Object.defineProperty(exports, "__esModule", ({ value: true }));
10654
10774
  exports.QueryInterface = exports.Connection = exports.Query = exports.MenuTarget = void 0;
10655
10775
  exports.getQueryName = getQueryName;
10656
10776
  const async_mutex_1 = __webpack_require__(/*! async-mutex */ "async-mutex");
10657
10777
  const promise_socket_1 = __importDefault(__webpack_require__(/*! promise-socket */ "promise-socket"));
10658
10778
  const net_1 = __webpack_require__(/*! net */ "net");
10779
+ const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
10659
10780
  const Telemetry = __importStar(__webpack_require__(/*! src/utils/telemetry */ "./src/utils/telemetry.ts"));
10660
- const types_1 = __webpack_require__(/*! ./message/types */ "./src/remotedb/message/types.ts");
10781
+ const device_id_1 = __webpack_require__(/*! src/virtualcdj/device-id */ "./src/virtualcdj/device-id.ts");
10782
+ const types_2 = __webpack_require__(/*! ./message/types */ "./src/remotedb/message/types.ts");
10661
10783
  const constants_1 = __webpack_require__(/*! ./constants */ "./src/remotedb/constants.ts");
10662
10784
  const fields_1 = __webpack_require__(/*! ./fields */ "./src/remotedb/fields.ts");
10663
10785
  const message_1 = __webpack_require__(/*! ./message */ "./src/remotedb/message/index.ts");
@@ -10670,7 +10792,7 @@ var MenuTarget;
10670
10792
  (function (MenuTarget) {
10671
10793
  MenuTarget[MenuTarget["Main"] = 1] = "Main";
10672
10794
  })(MenuTarget || (exports.MenuTarget = MenuTarget = {}));
10673
- exports.Query = types_1.Request;
10795
+ exports.Query = types_2.Request;
10674
10796
  const QueryInverse = Object.fromEntries(Object.entries(exports.Query).map(e => [e[1], e[0]]));
10675
10797
  /**
10676
10798
  * Returns a string representation of a remote query
@@ -10716,7 +10838,7 @@ class Connection {
10716
10838
  var _a;
10717
10839
  const tx = span.startChild({
10718
10840
  op: 'writeMessage',
10719
- description: (0, types_1.getMessageName)(message.type),
10841
+ description: (0, types_2.getMessageName)(message.type),
10720
10842
  });
10721
10843
  message.transactionId = __classPrivateFieldSet(this, _Connection_txId, (_a = __classPrivateFieldGet(this, _Connection_txId, "f"), ++_a), "f");
10722
10844
  await __classPrivateFieldGet(this, _Connection_socket, "f").write(message.buffer);
@@ -10777,7 +10899,8 @@ _QueryInterface_conn = new WeakMap(), _QueryInterface_hostDevice = new WeakMap()
10777
10899
  * Service that maintains remote database connections with devices on the network.
10778
10900
  */
10779
10901
  class RemoteDatabase {
10780
- constructor(deviceManager, hostDevice) {
10902
+ constructor(deviceManager, hostDevice, { pickQueryId = true } = {}) {
10903
+ _RemoteDatabase_instances.add(this);
10781
10904
  _RemoteDatabase_hostDevice.set(this, void 0);
10782
10905
  _RemoteDatabase_deviceManager.set(this, void 0);
10783
10906
  /**
@@ -10788,6 +10911,17 @@ class RemoteDatabase {
10788
10911
  * Locks for each device when locating the connection
10789
10912
  */
10790
10913
  _RemoteDatabase_deviceLocks.set(this, new Map());
10914
+ /**
10915
+ * The device ID each connection introduced itself with. Queries on that
10916
+ * connection must carry the same ID.
10917
+ */
10918
+ _RemoteDatabase_queryIds.set(this, new Map());
10919
+ /**
10920
+ * Whether to substitute an in-range query ID when the host device sits
10921
+ * outside 1-6. Disabled only by protocol probes that need to observe how a
10922
+ * device treats the raw announced ID (see examples/remotedb-id-probe.ts).
10923
+ */
10924
+ _RemoteDatabase_pickQueryId.set(this, void 0);
10791
10925
  /**
10792
10926
  * Open a connection to the specified device for querying
10793
10927
  */
@@ -10812,20 +10946,22 @@ class RemoteDatabase {
10812
10946
  throw new Error(`Expected 0x01 during preamble handshake. Got ${data.value}`);
10813
10947
  }
10814
10948
  // Send introduction message to set context for querying
10949
+ const queryId = __classPrivateFieldGet(this, _RemoteDatabase_instances, "m", _RemoteDatabase_queryIdFor).call(this, device);
10815
10950
  const intro = new message_1.Message({
10816
10951
  transactionId: 0xfffffffe,
10817
- type: types_1.MessageType.Introduce,
10818
- args: [new fields_1.UInt32(__classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id)],
10952
+ type: types_2.MessageType.Introduce,
10953
+ args: [new fields_1.UInt32(queryId)],
10819
10954
  });
10820
10955
  await socket.write(intro.buffer);
10821
- const resp = await message_1.Message.fromStream(socket, types_1.MessageType.Success, tx);
10822
- if (resp.type !== types_1.MessageType.Success) {
10956
+ const resp = await message_1.Message.fromStream(socket, types_2.MessageType.Success, tx);
10957
+ if (resp.type !== types_2.MessageType.Success) {
10823
10958
  throw new Error(`Failed to introduce self to device ID: ${device.id}`);
10824
10959
  }
10825
10960
  // Clear socket timeout after successful connection — the per-device mutex
10826
10961
  // and application-level Promise.race timeouts handle query timeouts
10827
10962
  socket.stream.setTimeout(0);
10828
10963
  __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").set(device.id, new Connection(device, socket));
10964
+ __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").set(device.id, queryId);
10829
10965
  tx.finish();
10830
10966
  };
10831
10967
  /**
@@ -10839,22 +10975,26 @@ class RemoteDatabase {
10839
10975
  }
10840
10976
  const goodbye = new message_1.Message({
10841
10977
  transactionId: 0xfffffffe,
10842
- type: types_1.MessageType.Disconnect,
10978
+ type: types_2.MessageType.Disconnect,
10843
10979
  args: [],
10844
10980
  });
10845
10981
  await conn.writeMessage(goodbye, tx);
10846
10982
  conn.close();
10847
10983
  __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").delete(device.id);
10984
+ __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").delete(device.id);
10848
10985
  tx.finish();
10849
10986
  };
10850
10987
  __classPrivateFieldSet(this, _RemoteDatabase_deviceManager, deviceManager, "f");
10851
10988
  __classPrivateFieldSet(this, _RemoteDatabase_hostDevice, hostDevice, "f");
10989
+ __classPrivateFieldSet(this, _RemoteDatabase_pickQueryId, pickQueryId, "f");
10852
10990
  }
10853
10991
  /**
10854
- * The device we introduce ourselves as when opening a remote database
10855
- * connection. CDJs only answer queries from a host announcing an ID in the
10856
- * 1-6 range, so callers need to read this before deciding a remote lookup is
10857
- * even possible.
10992
+ * The device this service belongs to — the virtual CDJ announced on the
10993
+ * network. Note that this is NOT necessarily the ID carried inside remotedb
10994
+ * messages: CDJs only answer queries whose in-protocol device-ID byte is
10995
+ * 1-6, so when this device sits outside that range (announced above the
10996
+ * player range to avoid collisions), each connection picks an in-range
10997
+ * query ID via {@link pickRemoteDbQueryId} instead.
10858
10998
  */
10859
10999
  get hostDevice() {
10860
11000
  return __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f");
@@ -10868,7 +11008,7 @@ class RemoteDatabase {
10868
11008
  * @returns null if the device does not export a remote database service
10869
11009
  */
10870
11010
  async get(deviceId) {
10871
- var _a;
11011
+ var _a, _b;
10872
11012
  const device = __classPrivateFieldGet(this, _RemoteDatabase_deviceManager, "f").devices.get(deviceId);
10873
11013
  if (device === undefined) {
10874
11014
  return null;
@@ -10883,14 +11023,28 @@ class RemoteDatabase {
10883
11023
  conn = __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").get(deviceId);
10884
11024
  // NOTE: We pass the same lock we use for this device to the query
10885
11025
  // interface to ensure all query interfaces use the same lock.
10886
- return new QueryInterface(conn, lock, __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f"));
11026
+ // Queries must carry the same device ID the connection introduced
11027
+ // itself with, which is not always the announced host ID.
11028
+ const queryId = (_b = __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").get(device.id)) !== null && _b !== void 0 ? _b : __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id;
11029
+ return new QueryInterface(conn, lock, { ...__classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f"), id: queryId });
10887
11030
  }
10888
11031
  finally {
10889
11032
  releaseLock();
10890
11033
  }
10891
11034
  }
10892
11035
  }
10893
- _RemoteDatabase_hostDevice = new WeakMap(), _RemoteDatabase_deviceManager = new WeakMap(), _RemoteDatabase_connections = new WeakMap(), _RemoteDatabase_deviceLocks = new WeakMap();
11036
+ _RemoteDatabase_hostDevice = new WeakMap(), _RemoteDatabase_deviceManager = new WeakMap(), _RemoteDatabase_connections = new WeakMap(), _RemoteDatabase_deviceLocks = new WeakMap(), _RemoteDatabase_queryIds = new WeakMap(), _RemoteDatabase_pickQueryId = new WeakMap(), _RemoteDatabase_instances = new WeakSet(), _RemoteDatabase_queryIdFor = function _RemoteDatabase_queryIdFor(device) {
11037
+ const hostId = __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id;
11038
+ // An announced ID already inside the answered range keeps working exactly
11039
+ // as it always has. Rekordbox (which numbers itself far above 6) answers
11040
+ // queries regardless, so only CDJ targets need an in-range stand-in.
11041
+ if (!__classPrivateFieldGet(this, _RemoteDatabase_pickQueryId, "f") ||
11042
+ hostId <= device_id_1.REMOTEDB_MAX_DEVICE_ID ||
11043
+ device.type !== types_1.DeviceType.CDJ) {
11044
+ return hostId;
11045
+ }
11046
+ return (0, device_id_1.pickRemoteDbQueryId)(device.id, __classPrivateFieldGet(this, _RemoteDatabase_deviceManager, "f").devices.values());
11047
+ };
10894
11048
  exports["default"] = RemoteDatabase;
10895
11049
 
10896
11050
 
@@ -12184,10 +12338,29 @@ exports["default"] = PositionEmitter;
12184
12338
  Object.defineProperty(exports, "__esModule", ({ value: true }));
12185
12339
  exports.PlayState = exports.StatusFlag = void 0;
12186
12340
  /**
12187
- * Status flag bitmasks
12341
+ * Status flag bitmasks (byte 0x89 of the CDJ status packet).
12342
+ *
12343
+ * Verified against CDJ-3000 firmware (EP122 FW3.20): the deck assembles this
12344
+ * byte in `sub_d7d3c8` (0xd7d3c8) from the BeatSyncMaster state struct, one
12345
+ * source boolean per bit. The bits not named here:
12346
+ *
12347
+ * - bit 0 (0x01): structurally unused — no code path ever sets it (always 0).
12348
+ * - bit 2 (0x04): a real, dedicated beat-sync boolean (struct +5), but its
12349
+ * name did not survive in the stripped `usecase::sync` async-task code. It
12350
+ * is normally 0, which is why it has never been observed on the wire.
12351
+ * - bit 7 (0x80): a real bit (struct +0xa) that is default-set at init and
12352
+ * whose de-assertion itself triggers a state-change notification — best read
12353
+ * as a "sync-master active / handoff-in-progress" toggle. Also normally 0 in
12354
+ * steady state.
12188
12355
  */
12189
12356
  var StatusFlag;
12190
12357
  (function (StatusFlag) {
12358
+ /**
12359
+ * Degraded to BPM Sync: the player is still tracking the master's tempo, but
12360
+ * beat alignment was dropped after a pitch-bend / jog nudge. Firmware sources
12361
+ * this from BeatSyncMaster struct +4.
12362
+ */
12363
+ StatusFlag[StatusFlag["BpmSync"] = 2] = "BpmSync";
12191
12364
  StatusFlag[StatusFlag["OnAir"] = 8] = "OnAir";
12192
12365
  StatusFlag[StatusFlag["Sync"] = 16] = "Sync";
12193
12366
  StatusFlag[StatusFlag["Master"] = 32] = "Master";
@@ -12260,14 +12433,24 @@ function statusFromPacket(packet) {
12260
12433
  playState: packet[0x7b],
12261
12434
  isOnAir: (packet[0x89] & types_1.CDJStatus.StatusFlag.OnAir) !== 0,
12262
12435
  isSync: (packet[0x89] & types_1.CDJStatus.StatusFlag.Sync) !== 0,
12436
+ isBpmSync: (packet[0x89] & types_1.CDJStatus.StatusFlag.BpmSync) !== 0,
12263
12437
  isMaster: (packet[0x89] & types_1.CDJStatus.StatusFlag.Master) !== 0,
12264
12438
  isEmergencyMode: !!packet[0xba],
12265
12439
  trackBPM,
12266
- sliderPitch: calcPitch(packet.slice(0x8d, 0x8d + 3)),
12267
- effectivePitch: calcPitch(packet.slice(0x99, 0x99 + 3)),
12440
+ // The CDJ status packet carries four pitch values (firmware CDJ-3000 FW3.20,
12441
+ // sub_d7d610): Pitch1@0x8c and Pitch3@0xc0 are the *effective* pitch (in
12442
+ // effect / on the BPM display, from the fader or a synced master), while
12443
+ // Pitch2@0x98 and Pitch4@0xc4 track the *local fader* position. We expose
12444
+ // Pitch1 as `effectivePitch` and Pitch2 as `sliderPitch`.
12445
+ effectivePitch: calcPitch(packet.slice(0x8d, 0x8d + 3)),
12446
+ sliderPitch: calcPitch(packet.slice(0x99, 0x99 + 3)),
12268
12447
  beatInMeasure: packet[0xa6],
12269
12448
  beatsUntilCue,
12270
12449
  beat,
12450
+ // Player-type / capability byte (dysentery's "nx"). Guarded because the
12451
+ // length check above only guarantees 0xc8; older/short packets may not
12452
+ // reach 0xcc, in which case the field reads as 0 (version-gated to 0 anyway).
12453
+ deviceType: packet.length > 0xcc ? packet[0xcc] : 0,
12271
12454
  packetNum: packet.readUInt32BE(0xc8),
12272
12455
  };
12273
12456
  return status;
@@ -13012,11 +13195,17 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
13012
13195
  exports.DEFAULT_MIXER_PLAYER_CEILING = exports.MIXER_PLAYER_CEILING = exports.MAX_DEVICE_ID = exports.REMOTEDB_MAX_DEVICE_ID = void 0;
13013
13196
  exports.playerNumberCeiling = playerNumberCeiling;
13014
13197
  exports.pickAvailableDeviceId = pickAvailableDeviceId;
13198
+ exports.pickRemoteDbQueryId = pickRemoteDbQueryId;
13015
13199
  /**
13016
13200
  * The highest device ID a CDJ will answer remotedb queries from.
13017
13201
  *
13018
- * CDJs ignore metadata requests from devices numbered above this, which is why
13019
- * the 1-6 range is worth preferring even though real players live there too.
13202
+ * This limit applies to the device-ID byte inside the remotedb protocol (the
13203
+ * Introduce message and every query header), NOT to the ID we announce on the
13204
+ * network. Hardware-verified on a CDJ-3000 (2026-08-30, two players + DJM-V5,
13205
+ * announced as VCDJ 7 throughout): queries carrying 1-6 are all answered —
13206
+ * including IDs of absent players and the target's own ID — while 7 and above
13207
+ * are silently ignored (the TCP connection and Introduce succeed, the query
13208
+ * never gets a response). See {@link pickRemoteDbQueryId}.
13020
13209
  */
13021
13210
  exports.REMOTEDB_MAX_DEVICE_ID = 6;
13022
13211
  /**
@@ -13024,11 +13213,13 @@ exports.REMOTEDB_MAX_DEVICE_ID = 6;
13024
13213
  */
13025
13214
  exports.MAX_DEVICE_ID = 32;
13026
13215
  /**
13027
- * `DeviceType.Mixer`, inlined so this module stays free of runtime imports —
13028
- * it is re-exported into consumers' test environments, where pulling in the
13029
- * whole type module (and its ip-address dependency) is dead weight.
13216
+ * `DeviceType.Mixer` and `DeviceType.CDJ`, inlined so this module stays free
13217
+ * of runtime imports — it is re-exported into consumers' test environments,
13218
+ * where pulling in the whole type module (and its ip-address dependency) is
13219
+ * dead weight.
13030
13220
  */
13031
13221
  const MIXER_DEVICE_TYPE = 0x03;
13222
+ const CDJ_DEVICE_TYPE = 0x01;
13032
13223
  /**
13033
13224
  * How many player numbers each mixer can hand out.
13034
13225
  *
@@ -13127,24 +13318,233 @@ const ascending = (from, to) => {
13127
13318
  * Returns null when every ID from 1 to 32 is occupied, in which case there is
13128
13319
  * no safe ID and the caller must not join the network.
13129
13320
  */
13130
- function pickAvailableDeviceId(usedIds, { preferRemoteDb = false, playerCeiling } = {}) {
13321
+ function pickAvailableDeviceId(usedIds, { preferPlayerRange = false, playerCeiling } = {}) {
13131
13322
  var _a;
13132
13323
  const used = new Set(usedIds);
13133
13324
  const aboveThePlayers = playerCeiling === undefined
13134
13325
  ? descending(exports.REMOTEDB_MAX_DEVICE_ID, 1)
13135
13326
  : ascending(playerCeiling + 1, exports.REMOTEDB_MAX_DEVICE_ID);
13136
- const outsideRemoteDb = ascending(exports.REMOTEDB_MAX_DEVICE_ID + 1, exports.MAX_DEVICE_ID);
13327
+ const outsidePlayerRange = ascending(exports.REMOTEDB_MAX_DEVICE_ID + 1, exports.MAX_DEVICE_ID);
13137
13328
  // Numbers a player on this rig could be assigned. Last resort: better to
13138
13329
  // contend for a slot than not to join at all, and the announcer moves us if
13139
13330
  // the player that owns it shows up.
13140
13331
  const amongThePlayers = playerCeiling === undefined
13141
13332
  ? []
13142
13333
  : descending(Math.min(playerCeiling, exports.REMOTEDB_MAX_DEVICE_ID), 1);
13143
- const search = preferRemoteDb
13144
- ? [...aboveThePlayers, ...outsideRemoteDb, ...amongThePlayers]
13145
- : [...outsideRemoteDb, ...aboveThePlayers, ...amongThePlayers];
13334
+ const search = preferPlayerRange
13335
+ ? [...aboveThePlayers, ...outsidePlayerRange, ...amongThePlayers]
13336
+ : [...outsidePlayerRange, ...aboveThePlayers, ...amongThePlayers];
13146
13337
  return (_a = search.find(id => !used.has(id))) !== null && _a !== void 0 ? _a : null;
13147
13338
  }
13339
+ /**
13340
+ * Choose the device ID to carry inside remotedb messages when querying a CDJ.
13341
+ *
13342
+ * CDJs only answer remotedb queries whose in-protocol device-ID byte is in
13343
+ * 1-6, but that byte is independent of the ID we announce on the network — so
13344
+ * a virtual CDJ announced safely above the player range (say 7 behind a
13345
+ * DJM-V10) can still query metadata by carrying an in-range ID here. Verified
13346
+ * on a CDJ-3000 (2026-08-30): absent IDs and even the target's own ID are
13347
+ * answered; 7+ is silently dropped.
13348
+ *
13349
+ * Older players are documented (dysentery, nexus era) as stricter: the byte
13350
+ * had to be 1-4, belong to a player actually on the network, and not be the
13351
+ * player being queried. The preference order below satisfies those rules
13352
+ * whenever the rig makes it possible, then falls back through choices newer
13353
+ * players accept.
13354
+ */
13355
+ function pickRemoteDbQueryId(targetId, devices) {
13356
+ const playerIds = new Set();
13357
+ for (const device of devices) {
13358
+ if (device.type === CDJ_DEVICE_TYPE && device.id <= exports.REMOTEDB_MAX_DEVICE_ID) {
13359
+ playerIds.add(device.id);
13360
+ }
13361
+ }
13362
+ // A live player that isn't the target, lowest first — 1-4 satisfies every
13363
+ // documented era of the restriction.
13364
+ const otherPlayers = [...playerIds].filter(id => id !== targetId).sort((a, b) => a - b);
13365
+ if (otherPlayers.length > 0) {
13366
+ return otherPlayers[0];
13367
+ }
13368
+ // No other player on the rig: take the lowest 1-6 ID no player holds.
13369
+ for (let id = 1; id <= exports.REMOTEDB_MAX_DEVICE_ID; id++) {
13370
+ if (id !== targetId && !playerIds.has(id)) {
13371
+ return id;
13372
+ }
13373
+ }
13374
+ // Six players and every one of them is the target? Impossible, but the
13375
+ // target's own ID is answered too, so it is a safe closing fallback.
13376
+ return targetId;
13377
+ }
13378
+
13379
+
13380
+ /***/ },
13381
+
13382
+ /***/ "./src/virtualcdj/heartbeat.ts"
13383
+ /*!*************************************!*\
13384
+ !*** ./src/virtualcdj/heartbeat.ts ***!
13385
+ \*************************************/
13386
+ (__unused_webpack_module, exports, __webpack_require__) {
13387
+
13388
+ "use strict";
13389
+
13390
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
13391
+ if (kind === "m") throw new TypeError("Private method is not writable");
13392
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
13393
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
13394
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
13395
+ };
13396
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
13397
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
13398
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
13399
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
13400
+ };
13401
+ var _StagehandHeartbeat_instances, _StagehandHeartbeat_statusSocket, _StagehandHeartbeat_vcdj, _StagehandHeartbeat_deviceManager, _StagehandHeartbeat_logger, _StagehandHeartbeat_intervalHandle, _StagehandHeartbeat_sendHeartbeats;
13402
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
13403
+ exports.StagehandHeartbeat = exports.STAGEHAND_HEARTBEAT_INTERVAL = void 0;
13404
+ exports.makeStagehandMixerHeartbeat = makeStagehandMixerHeartbeat;
13405
+ exports.makeStagehandPlayerHeartbeat = makeStagehandPlayerHeartbeat;
13406
+ const constants_1 = __webpack_require__(/*! src/constants */ "./src/constants.ts");
13407
+ const logger_1 = __webpack_require__(/*! src/logger */ "./src/logger.ts");
13408
+ const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
13409
+ /**
13410
+ * Cadence of the Stagehand unicast keep-alive, in milliseconds.
13411
+ *
13412
+ * The real iOS Stagehand app heartbeats every discovered player and the mixer
13413
+ * at ~4 Hz (250ms median inter-packet gap in captured traffic). This is the
13414
+ * stream that keeps AlphaTheta hardware unicasting live state (`0x39` mixer
13415
+ * fader/EQ, `0x58` VU on the mixer; `0x69` slim status, waveform families on
13416
+ * the CDJs) to our IP — subnet-broadcast presence alone (the `0x06` keep-alive)
13417
+ * is not enough to bootstrap it.
13418
+ */
13419
+ exports.STAGEHAND_HEARTBEAT_INTERVAL = 250;
13420
+ /**
13421
+ * Builds a Stagehand name field for a unicast frame.
13422
+ *
13423
+ * Unlike the 20-byte {@link buildName} used by the broadcast announce/claim
13424
+ * frames, unicast frames carry a 19-byte name field (offsets 11..29) with the
13425
+ * device type / model bytes beginning at offset 30. The name is ASCII, NUL
13426
+ * padded.
13427
+ */
13428
+ function buildUnicastName(name) {
13429
+ const field = new Uint8Array(19);
13430
+ field.set(Buffer.from(name, 'ascii').subarray(0, 19));
13431
+ return field;
13432
+ }
13433
+ /**
13434
+ * Build the Stagehand mixer keep-alive (`0x3a`, 40 bytes).
13435
+ *
13436
+ * Verified byte-for-byte against the real iOS Stagehand app unicasting to a
13437
+ * DJM-A9 at 4 Hz (Phase 4/5 captures §4.14.2). The 10-byte trailer
13438
+ * `21 02 00 fe 00 04 00 1c 00 00` is constant across every observed sample —
13439
+ * byte 33 (`0xfe`) is a VCDJ sentinel, not our runtime device number, so no
13440
+ * per-device bytes vary here.
13441
+ */
13442
+ function makeStagehandMixerHeartbeat(vcdj) {
13443
+ const parts = [
13444
+ ...constants_1.PROLINK_HEADER, // 0-9: magic
13445
+ 0x3a, // 10: type 0x3a (mixer keep-alive)
13446
+ ...buildUnicastName(vcdj.name), // 11-29: name (19 bytes)
13447
+ 0x21,
13448
+ 0x02,
13449
+ 0x00,
13450
+ 0xfe,
13451
+ 0x00,
13452
+ 0x04,
13453
+ 0x00,
13454
+ 0x1c,
13455
+ 0x00,
13456
+ 0x00, // 30-39: constant trailer
13457
+ ];
13458
+ return Uint8Array.from(parts);
13459
+ }
13460
+ /**
13461
+ * Build the Stagehand player keep-alive (`0x68`, 36 bytes).
13462
+ *
13463
+ * Verified byte-for-byte against the real iOS Stagehand app unicasting to a
13464
+ * CDJ-3000 at ~2-4 Hz. Body (offsets 30-35) is `03 01 00 3a 00 00`: `0x03`
13465
+ * persona/channel, `0x01` device subkind, `0x00` flag, `0x3a` Stagehand
13466
+ * model-code stamp, two reserved bytes. Constant across every observed sample.
13467
+ */
13468
+ function makeStagehandPlayerHeartbeat(vcdj) {
13469
+ const parts = [
13470
+ ...constants_1.PROLINK_HEADER, // 0-9: magic
13471
+ 0x68, // 10: type 0x68 (player keep-alive)
13472
+ ...buildUnicastName(vcdj.name), // 11-29: name (19 bytes)
13473
+ 0x03,
13474
+ 0x01,
13475
+ 0x00,
13476
+ 0x3a,
13477
+ 0x00,
13478
+ 0x00, // 30-35: constant body
13479
+ ];
13480
+ return Uint8Array.from(parts);
13481
+ }
13482
+ /**
13483
+ * Unicasts Stagehand keep-alive frames to every discovered player and mixer so
13484
+ * that AlphaTheta hardware begins (and keeps) pushing live state to our IP.
13485
+ *
13486
+ * The {@link StagehandAnnouncer} makes us *visible* on the network via subnet
13487
+ * broadcast; this heartbeat is what makes hardware actually *talk back*. It
13488
+ * targets the mixer with `0x3a` and each CDJ with `0x68`, mirroring the real
13489
+ * iOS Stagehand app. Frames are sent from the status socket (port 50002) to
13490
+ * each device's port 50002, which is where their unicast state replies land and
13491
+ * where {@link StatusEmitter} listens for them.
13492
+ */
13493
+ class StagehandHeartbeat {
13494
+ constructor(vcdj, statusSocket, deviceManager, logger = logger_1.noopLogger) {
13495
+ _StagehandHeartbeat_instances.add(this);
13496
+ _StagehandHeartbeat_statusSocket.set(this, void 0);
13497
+ _StagehandHeartbeat_vcdj.set(this, void 0);
13498
+ _StagehandHeartbeat_deviceManager.set(this, void 0);
13499
+ _StagehandHeartbeat_logger.set(this, void 0);
13500
+ _StagehandHeartbeat_intervalHandle.set(this, void 0);
13501
+ __classPrivateFieldSet(this, _StagehandHeartbeat_vcdj, vcdj, "f");
13502
+ __classPrivateFieldSet(this, _StagehandHeartbeat_statusSocket, statusSocket, "f");
13503
+ __classPrivateFieldSet(this, _StagehandHeartbeat_deviceManager, deviceManager, "f");
13504
+ __classPrivateFieldSet(this, _StagehandHeartbeat_logger, logger, "f");
13505
+ }
13506
+ start() {
13507
+ if (__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f") !== undefined) {
13508
+ return;
13509
+ }
13510
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").info('Starting Stagehand heartbeat (unicast keep-alive)');
13511
+ __classPrivateFieldGet(this, _StagehandHeartbeat_instances, "m", _StagehandHeartbeat_sendHeartbeats).call(this);
13512
+ __classPrivateFieldSet(this, _StagehandHeartbeat_intervalHandle, setInterval(() => __classPrivateFieldGet(this, _StagehandHeartbeat_instances, "m", _StagehandHeartbeat_sendHeartbeats).call(this), exports.STAGEHAND_HEARTBEAT_INTERVAL), "f");
13513
+ }
13514
+ stop() {
13515
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").info('Stopping Stagehand heartbeat');
13516
+ if (__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f") !== undefined) {
13517
+ clearInterval(__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f"));
13518
+ __classPrivateFieldSet(this, _StagehandHeartbeat_intervalHandle, undefined, "f");
13519
+ }
13520
+ }
13521
+ }
13522
+ exports.StagehandHeartbeat = StagehandHeartbeat;
13523
+ _StagehandHeartbeat_statusSocket = new WeakMap(), _StagehandHeartbeat_vcdj = new WeakMap(), _StagehandHeartbeat_deviceManager = new WeakMap(), _StagehandHeartbeat_logger = new WeakMap(), _StagehandHeartbeat_intervalHandle = new WeakMap(), _StagehandHeartbeat_instances = new WeakSet(), _StagehandHeartbeat_sendHeartbeats = function _StagehandHeartbeat_sendHeartbeats() {
13524
+ for (const device of __classPrivateFieldGet(this, _StagehandHeartbeat_deviceManager, "f").devices.values()) {
13525
+ // Never heartbeat ourselves.
13526
+ if (device.id === __classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f").id) {
13527
+ continue;
13528
+ }
13529
+ let packet = null;
13530
+ switch (device.type) {
13531
+ case types_1.DeviceType.Mixer:
13532
+ packet = makeStagehandMixerHeartbeat(__classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f"));
13533
+ break;
13534
+ case types_1.DeviceType.CDJ:
13535
+ packet = makeStagehandPlayerHeartbeat(__classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f"));
13536
+ break;
13537
+ default:
13538
+ // Rekordbox / other Stagehand peers are not heartbeat targets.
13539
+ continue;
13540
+ }
13541
+ __classPrivateFieldGet(this, _StagehandHeartbeat_statusSocket, "f").send(packet, constants_1.STATUS_PORT, device.ip.address, err => {
13542
+ if (err) {
13543
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").debug(`Stagehand heartbeat to ${device.name} (${device.ip.address}) failed: ${err.message}`);
13544
+ }
13545
+ });
13546
+ }
13547
+ };
13148
13548
 
13149
13549
 
13150
13550
  /***/ },
@@ -13573,16 +13973,16 @@ _Announcer_announceSocket = new WeakMap(), _Announcer_deviceManager = new WeakMa
13573
13973
  }, _Announcer_handleConflict = function _Announcer_handleConflict() {
13574
13974
  // Stop announcing under the contested ID
13575
13975
  __classPrivateFieldGet(this, _Announcer_instances, "m", _Announcer_clearTimer).call(this);
13576
- // Stay in whichever range we were already in. Dropping out of 1-6 would
13577
- // silently lose remotedb metadata for unanalyzed and streaming tracks, so
13578
- // a conflict on player 5 looks for another free player number first.
13976
+ // Stay in whichever range we were already in, so a conflict on player 5
13977
+ // looks for another free player number first rather than jumping the
13978
+ // whole device out of the range the DJ configured it into.
13579
13979
  //
13580
13980
  // The mixer decides which of those numbers are actually safe: a player can
13581
13981
  // only be assigned a number its mixer has a channel for, so anything above
13582
13982
  // that is out of reach of the rig that just contested us.
13583
13983
  const devices = [...__classPrivateFieldGet(this, _Announcer_deviceManager, "f").devices.values()];
13584
13984
  const newId = (0, device_id_1.pickAvailableDeviceId)(devices.map(d => d.id), {
13585
- preferRemoteDb: __classPrivateFieldGet(this, _Announcer_vcdj, "f").id <= device_id_1.REMOTEDB_MAX_DEVICE_ID,
13985
+ preferPlayerRange: __classPrivateFieldGet(this, _Announcer_vcdj, "f").id <= device_id_1.REMOTEDB_MAX_DEVICE_ID,
13586
13986
  playerCeiling: (0, device_id_1.playerNumberCeiling)(devices),
13587
13987
  });
13588
13988
  if (newId === null) {
@@ -13685,6 +14085,7 @@ _Announcer_announceSocket = new WeakMap(), _Announcer_deviceManager = new WeakMa
13685
14085
  __classPrivateFieldSet(this, _Announcer_intervalHandle, setInterval(sendKeepAlive, constants_1.ANNOUNCE_INTERVAL), "f");
13686
14086
  };
13687
14087
  __exportStar(__webpack_require__(/*! ./device-id */ "./src/virtualcdj/device-id.ts"), exports);
14088
+ __exportStar(__webpack_require__(/*! ./heartbeat */ "./src/virtualcdj/heartbeat.ts"), exports);
13688
14089
  __exportStar(__webpack_require__(/*! ./stagehand */ "./src/virtualcdj/stagehand.ts"), exports);
13689
14090
 
13690
14091