alphatheta-connect 0.23.2 → 0.25.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/lib/cli.js CHANGED
@@ -742,8 +742,10 @@ exports.MAX_CDJ_DEVICE_ID = exports.MIN_CDJ_DEVICE_ID = exports.VIRTUAL_CDJ_FIRM
742
742
  /**
743
743
  * The default virtual CDJ ID to use.
744
744
  *
745
- * This particular ID is out of the 1-6 range, thus will not be able to request
746
- * metadata via the remotedb for CDJs.
745
+ * Out of the 1-6 player range, so it can never collide with a real CDJ.
746
+ * Remotedb metadata still works from here: the 1-6 restriction applies to the
747
+ * device-ID byte inside remotedb messages, which RemoteDatabase picks
748
+ * per-connection independently of the announced ID.
747
749
  */
748
750
  exports.DEFAULT_VCDJ_ID = 0x07;
749
751
  /**
@@ -1647,7 +1649,6 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
1647
1649
  };
1648
1650
  var _Database_instances, _Database_deviceManager, _Database_localDatabase, _Database_remoteDatabase, _Database_logger, _Database_getTrackLookupStrategy, _Database_getMediaLookupStrategy, _Database_metadataViaRemoteFallback;
1649
1651
  Object.defineProperty(exports, "__esModule", ({ value: true }));
1650
- const constants_1 = __webpack_require__(/*! src/constants */ "./src/constants.ts");
1651
1652
  const logger_1 = __webpack_require__(/*! src/logger */ "./src/logger.ts");
1652
1653
  const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
1653
1654
  const utils_1 = __webpack_require__(/*! src/utils */ "./src/utils/index.ts");
@@ -1947,20 +1948,14 @@ _Database_deviceManager = new WeakMap(), _Database_localDatabase = new WeakMap()
1947
1948
  /**
1948
1949
  * Second chance for a track the local database could not answer for.
1949
1950
  *
1950
- * CDJs only answer remote database queries from a host announcing a device
1951
- * ID in the 1-6 range, so this is unavailable whenever the virtual CDJ sits
1952
- * outside it (the package default is 7). That is a real configuration, not
1953
- * an error log why the fallback was skipped rather than throwing, so the
1954
- * next report says which of the two happened.
1951
+ * Available regardless of the virtual CDJ's announced ID: CDJs restrict
1952
+ * remote database queries to a device-ID byte in the 1-6 range, but that
1953
+ * byte lives inside the remotedb messages and RemoteDatabase picks an
1954
+ * in-range one per connection the announced ID never constrains this
1955
+ * lookup. A failure here is logged rather than thrown, so a field report
1956
+ * says what actually went wrong.
1955
1957
  */
1956
1958
  async function _Database_metadataViaRemoteFallback(opts) {
1957
- const hostId = __classPrivateFieldGet(this, _Database_remoteDatabase, "f").hostDevice.id;
1958
- if (hostId < constants_1.MIN_CDJ_DEVICE_ID || hostId > constants_1.MAX_CDJ_DEVICE_ID) {
1959
- __classPrivateFieldGet(this, _Database_logger, "f").warn(`No remote fallback for track ${opts.trackId}: this player is device ` +
1960
- `${hostId}, and CDJs only answer remote database queries from ` +
1961
- `devices ${constants_1.MIN_CDJ_DEVICE_ID}-${constants_1.MAX_CDJ_DEVICE_ID}`);
1962
- return null;
1963
- }
1964
1959
  try {
1965
1960
  const track = await GetMetadata.viaRemote(__classPrivateFieldGet(this, _Database_remoteDatabase, "f"), opts);
1966
1961
  if (track === null) {
@@ -6718,7 +6713,7 @@ class MixstatusProcessor {
6718
6713
  // able to report it as live
6719
6714
  const startedAt = __classPrivateFieldGet(this, _MixstatusProcessor_lastStartTime, "f").get(deviceId);
6720
6715
  const requiredPlayTime = __classPrivateFieldGet(this, _MixstatusProcessor_config, "f").beatsUntilReported *
6721
- (0, utils_1.bpmToSeconds)(state.trackBPM, state.sliderPitch) *
6716
+ (0, utils_1.bpmToSeconds)(state.trackBPM, state.effectivePitch) *
6722
6717
  1000;
6723
6718
  if (__classPrivateFieldGet(this, _MixstatusProcessor_config, "f").mode === types_1.MixstatusMode.SmartTiming &&
6724
6719
  startedAt !== undefined &&
@@ -6729,7 +6724,7 @@ class MixstatusProcessor {
6729
6724
  // we can mark the track as truly stopped.
6730
6725
  const stoppedAt = __classPrivateFieldGet(this, _MixstatusProcessor_lastStoppedTimes, "f").get(deviceId);
6731
6726
  const requiredStopTime = __classPrivateFieldGet(this, _MixstatusProcessor_config, "f").allowedInterruptBeats *
6732
- (0, utils_1.bpmToSeconds)(state.trackBPM, state.sliderPitch) *
6727
+ (0, utils_1.bpmToSeconds)(state.trackBPM, state.effectivePitch) *
6733
6728
  1000;
6734
6729
  if (stoppedAt !== undefined && requiredStopTime <= Date.now() - stoppedAt) {
6735
6730
  __classPrivateFieldGet(this, _MixstatusProcessor_markPlayerStopped, "f").call(this, state);
@@ -7020,6 +7015,14 @@ class ProlinkNetwork {
7020
7015
  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"));
7021
7016
  }
7022
7017
  announcer.start();
7018
+ // In Stagehand mode, unicast keep-alives to discovered players and the
7019
+ // mixer so they push live state to us (mixer 0x39/0x58, CDJ 0x69/waveforms).
7020
+ // Broadcast presence alone does not bootstrap that stream.
7021
+ let heartbeat = null;
7022
+ if (connectMethod === 'stagehand') {
7023
+ heartbeat = new virtualcdj_1.StagehandHeartbeat(vcdj, __classPrivateFieldGet(this, _ProlinkNetwork_statusSocket, "f"), __classPrivateFieldGet(this, _ProlinkNetwork_deviceManager, "f"), __classPrivateFieldGet(this, _ProlinkNetwork_logger, "f"));
7024
+ heartbeat.start();
7025
+ }
7023
7026
  // Create remote and local databases
7024
7027
  const remotedb = new remotedb_1.default(__classPrivateFieldGet(this, _ProlinkNetwork_deviceManager, "f"), vcdj);
7025
7028
  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');
@@ -7028,23 +7031,24 @@ class ProlinkNetwork {
7028
7031
  // Create controller service
7029
7032
  const control = new control_1.default(__classPrivateFieldGet(this, _ProlinkNetwork_beatSocket, "f"), vcdj);
7030
7033
  __classPrivateFieldSet(this, _ProlinkNetwork_state, types_1.NetworkState.Connected, "f");
7031
- __classPrivateFieldSet(this, _ProlinkNetwork_connection, { announcer, control, remotedb, localdb, database }, "f");
7034
+ __classPrivateFieldSet(this, _ProlinkNetwork_connection, { announcer, heartbeat, control, remotedb, localdb, database }, "f");
7032
7035
  tx.finish();
7033
7036
  }
7034
7037
  /**
7035
7038
  * Disconnect from the network
7036
7039
  */
7037
7040
  disconnect() {
7038
- var _a, _b, _c;
7041
+ var _a, _b, _c, _d, _e;
7039
7042
  if (__classPrivateFieldGet(this, _ProlinkNetwork_config, "f") === null) {
7040
7043
  throw new Error(connectErrorHelp);
7041
7044
  }
7042
7045
  // Stop announcing ourself
7043
7046
  (_a = __classPrivateFieldGet(this, _ProlinkNetwork_connection, "f")) === null || _a === void 0 ? void 0 : _a.announcer.stop();
7047
+ (_c = (_b = __classPrivateFieldGet(this, _ProlinkNetwork_connection, "f")) === null || _b === void 0 ? void 0 : _b.heartbeat) === null || _c === void 0 ? void 0 : _c.stop();
7044
7048
  // Disconnect devices from the remote and local databases
7045
7049
  for (const device of this.deviceManager.devices.values()) {
7046
- (_b = this.remotedb) === null || _b === void 0 ? void 0 : _b.disconnectFromDevice(device);
7047
- (_c = this.localdb) === null || _c === void 0 ? void 0 : _c.disconnectForDevice(device);
7050
+ (_d = this.remotedb) === null || _d === void 0 ? void 0 : _d.disconnectFromDevice(device);
7051
+ (_e = this.localdb) === null || _e === void 0 ? void 0 : _e.disconnectForDevice(device);
7048
7052
  }
7049
7053
  return this.close;
7050
7054
  }
@@ -8504,15 +8508,17 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
8504
8508
  var __importDefault = (this && this.__importDefault) || function (mod) {
8505
8509
  return (mod && mod.__esModule) ? mod : { "default": mod };
8506
8510
  };
8507
- var _Connection_socket, _Connection_txId, _Connection_lock, _QueryInterface_conn, _QueryInterface_hostDevice, _QueryInterface_lock, _RemoteDatabase_hostDevice, _RemoteDatabase_deviceManager, _RemoteDatabase_connections, _RemoteDatabase_deviceLocks;
8511
+ 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;
8508
8512
  Object.defineProperty(exports, "__esModule", ({ value: true }));
8509
8513
  exports.QueryInterface = exports.Connection = exports.Query = exports.MenuTarget = void 0;
8510
8514
  exports.getQueryName = getQueryName;
8511
8515
  const async_mutex_1 = __webpack_require__(/*! async-mutex */ "async-mutex");
8512
8516
  const promise_socket_1 = __importDefault(__webpack_require__(/*! promise-socket */ "promise-socket"));
8513
8517
  const net_1 = __webpack_require__(/*! net */ "net");
8518
+ const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
8514
8519
  const Telemetry = __importStar(__webpack_require__(/*! src/utils/telemetry */ "./src/utils/telemetry.ts"));
8515
- const types_1 = __webpack_require__(/*! ./message/types */ "./src/remotedb/message/types.ts");
8520
+ const device_id_1 = __webpack_require__(/*! src/virtualcdj/device-id */ "./src/virtualcdj/device-id.ts");
8521
+ const types_2 = __webpack_require__(/*! ./message/types */ "./src/remotedb/message/types.ts");
8516
8522
  const constants_1 = __webpack_require__(/*! ./constants */ "./src/remotedb/constants.ts");
8517
8523
  const fields_1 = __webpack_require__(/*! ./fields */ "./src/remotedb/fields.ts");
8518
8524
  const message_1 = __webpack_require__(/*! ./message */ "./src/remotedb/message/index.ts");
@@ -8525,7 +8531,7 @@ var MenuTarget;
8525
8531
  (function (MenuTarget) {
8526
8532
  MenuTarget[MenuTarget["Main"] = 1] = "Main";
8527
8533
  })(MenuTarget || (exports.MenuTarget = MenuTarget = {}));
8528
- exports.Query = types_1.Request;
8534
+ exports.Query = types_2.Request;
8529
8535
  const QueryInverse = Object.fromEntries(Object.entries(exports.Query).map(e => [e[1], e[0]]));
8530
8536
  /**
8531
8537
  * Returns a string representation of a remote query
@@ -8571,7 +8577,7 @@ class Connection {
8571
8577
  var _a;
8572
8578
  const tx = span.startChild({
8573
8579
  op: 'writeMessage',
8574
- description: (0, types_1.getMessageName)(message.type),
8580
+ description: (0, types_2.getMessageName)(message.type),
8575
8581
  });
8576
8582
  message.transactionId = __classPrivateFieldSet(this, _Connection_txId, (_a = __classPrivateFieldGet(this, _Connection_txId, "f"), ++_a), "f");
8577
8583
  await __classPrivateFieldGet(this, _Connection_socket, "f").write(message.buffer);
@@ -8632,7 +8638,8 @@ _QueryInterface_conn = new WeakMap(), _QueryInterface_hostDevice = new WeakMap()
8632
8638
  * Service that maintains remote database connections with devices on the network.
8633
8639
  */
8634
8640
  class RemoteDatabase {
8635
- constructor(deviceManager, hostDevice) {
8641
+ constructor(deviceManager, hostDevice, { pickQueryId = true } = {}) {
8642
+ _RemoteDatabase_instances.add(this);
8636
8643
  _RemoteDatabase_hostDevice.set(this, void 0);
8637
8644
  _RemoteDatabase_deviceManager.set(this, void 0);
8638
8645
  /**
@@ -8643,6 +8650,17 @@ class RemoteDatabase {
8643
8650
  * Locks for each device when locating the connection
8644
8651
  */
8645
8652
  _RemoteDatabase_deviceLocks.set(this, new Map());
8653
+ /**
8654
+ * The device ID each connection introduced itself with. Queries on that
8655
+ * connection must carry the same ID.
8656
+ */
8657
+ _RemoteDatabase_queryIds.set(this, new Map());
8658
+ /**
8659
+ * Whether to substitute an in-range query ID when the host device sits
8660
+ * outside 1-6. Disabled only by protocol probes that need to observe how a
8661
+ * device treats the raw announced ID (see examples/remotedb-id-probe.ts).
8662
+ */
8663
+ _RemoteDatabase_pickQueryId.set(this, void 0);
8646
8664
  /**
8647
8665
  * Open a connection to the specified device for querying
8648
8666
  */
@@ -8667,20 +8685,22 @@ class RemoteDatabase {
8667
8685
  throw new Error(`Expected 0x01 during preamble handshake. Got ${data.value}`);
8668
8686
  }
8669
8687
  // Send introduction message to set context for querying
8688
+ const queryId = __classPrivateFieldGet(this, _RemoteDatabase_instances, "m", _RemoteDatabase_queryIdFor).call(this, device);
8670
8689
  const intro = new message_1.Message({
8671
8690
  transactionId: 0xfffffffe,
8672
- type: types_1.MessageType.Introduce,
8673
- args: [new fields_1.UInt32(__classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id)],
8691
+ type: types_2.MessageType.Introduce,
8692
+ args: [new fields_1.UInt32(queryId)],
8674
8693
  });
8675
8694
  await socket.write(intro.buffer);
8676
- const resp = await message_1.Message.fromStream(socket, types_1.MessageType.Success, tx);
8677
- if (resp.type !== types_1.MessageType.Success) {
8695
+ const resp = await message_1.Message.fromStream(socket, types_2.MessageType.Success, tx);
8696
+ if (resp.type !== types_2.MessageType.Success) {
8678
8697
  throw new Error(`Failed to introduce self to device ID: ${device.id}`);
8679
8698
  }
8680
8699
  // Clear socket timeout after successful connection — the per-device mutex
8681
8700
  // and application-level Promise.race timeouts handle query timeouts
8682
8701
  socket.stream.setTimeout(0);
8683
8702
  __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").set(device.id, new Connection(device, socket));
8703
+ __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").set(device.id, queryId);
8684
8704
  tx.finish();
8685
8705
  };
8686
8706
  /**
@@ -8694,22 +8714,26 @@ class RemoteDatabase {
8694
8714
  }
8695
8715
  const goodbye = new message_1.Message({
8696
8716
  transactionId: 0xfffffffe,
8697
- type: types_1.MessageType.Disconnect,
8717
+ type: types_2.MessageType.Disconnect,
8698
8718
  args: [],
8699
8719
  });
8700
8720
  await conn.writeMessage(goodbye, tx);
8701
8721
  conn.close();
8702
8722
  __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").delete(device.id);
8723
+ __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").delete(device.id);
8703
8724
  tx.finish();
8704
8725
  };
8705
8726
  __classPrivateFieldSet(this, _RemoteDatabase_deviceManager, deviceManager, "f");
8706
8727
  __classPrivateFieldSet(this, _RemoteDatabase_hostDevice, hostDevice, "f");
8728
+ __classPrivateFieldSet(this, _RemoteDatabase_pickQueryId, pickQueryId, "f");
8707
8729
  }
8708
8730
  /**
8709
- * The device we introduce ourselves as when opening a remote database
8710
- * connection. CDJs only answer queries from a host announcing an ID in the
8711
- * 1-6 range, so callers need to read this before deciding a remote lookup is
8712
- * even possible.
8731
+ * The device this service belongs to the virtual CDJ announced on the
8732
+ * network. Note that this is NOT necessarily the ID carried inside remotedb
8733
+ * messages: CDJs only answer queries whose in-protocol device-ID byte is
8734
+ * 1-6, so when this device sits outside that range (announced above the
8735
+ * player range to avoid collisions), each connection picks an in-range
8736
+ * query ID via {@link pickRemoteDbQueryId} instead.
8713
8737
  */
8714
8738
  get hostDevice() {
8715
8739
  return __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f");
@@ -8723,7 +8747,7 @@ class RemoteDatabase {
8723
8747
  * @returns null if the device does not export a remote database service
8724
8748
  */
8725
8749
  async get(deviceId) {
8726
- var _a;
8750
+ var _a, _b;
8727
8751
  const device = __classPrivateFieldGet(this, _RemoteDatabase_deviceManager, "f").devices.get(deviceId);
8728
8752
  if (device === undefined) {
8729
8753
  return null;
@@ -8738,14 +8762,28 @@ class RemoteDatabase {
8738
8762
  conn = __classPrivateFieldGet(this, _RemoteDatabase_connections, "f").get(deviceId);
8739
8763
  // NOTE: We pass the same lock we use for this device to the query
8740
8764
  // interface to ensure all query interfaces use the same lock.
8741
- return new QueryInterface(conn, lock, __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f"));
8765
+ // Queries must carry the same device ID the connection introduced
8766
+ // itself with, which is not always the announced host ID.
8767
+ const queryId = (_b = __classPrivateFieldGet(this, _RemoteDatabase_queryIds, "f").get(device.id)) !== null && _b !== void 0 ? _b : __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id;
8768
+ return new QueryInterface(conn, lock, { ...__classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f"), id: queryId });
8742
8769
  }
8743
8770
  finally {
8744
8771
  releaseLock();
8745
8772
  }
8746
8773
  }
8747
8774
  }
8748
- _RemoteDatabase_hostDevice = new WeakMap(), _RemoteDatabase_deviceManager = new WeakMap(), _RemoteDatabase_connections = new WeakMap(), _RemoteDatabase_deviceLocks = new WeakMap();
8775
+ _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) {
8776
+ const hostId = __classPrivateFieldGet(this, _RemoteDatabase_hostDevice, "f").id;
8777
+ // An announced ID already inside the answered range keeps working exactly
8778
+ // as it always has. Rekordbox (which numbers itself far above 6) answers
8779
+ // queries regardless, so only CDJ targets need an in-range stand-in.
8780
+ if (!__classPrivateFieldGet(this, _RemoteDatabase_pickQueryId, "f") ||
8781
+ hostId <= device_id_1.REMOTEDB_MAX_DEVICE_ID ||
8782
+ device.type !== types_1.DeviceType.CDJ) {
8783
+ return hostId;
8784
+ }
8785
+ return (0, device_id_1.pickRemoteDbQueryId)(device.id, __classPrivateFieldGet(this, _RemoteDatabase_deviceManager, "f").devices.values());
8786
+ };
8749
8787
  exports["default"] = RemoteDatabase;
8750
8788
 
8751
8789
 
@@ -10039,10 +10077,29 @@ exports["default"] = PositionEmitter;
10039
10077
  Object.defineProperty(exports, "__esModule", ({ value: true }));
10040
10078
  exports.PlayState = exports.StatusFlag = void 0;
10041
10079
  /**
10042
- * Status flag bitmasks
10080
+ * Status flag bitmasks (byte 0x89 of the CDJ status packet).
10081
+ *
10082
+ * Verified against CDJ-3000 firmware (EP122 FW3.20): the deck assembles this
10083
+ * byte in `sub_d7d3c8` (0xd7d3c8) from the BeatSyncMaster state struct, one
10084
+ * source boolean per bit. The bits not named here:
10085
+ *
10086
+ * - bit 0 (0x01): structurally unused — no code path ever sets it (always 0).
10087
+ * - bit 2 (0x04): a real, dedicated beat-sync boolean (struct +5), but its
10088
+ * name did not survive in the stripped `usecase::sync` async-task code. It
10089
+ * is normally 0, which is why it has never been observed on the wire.
10090
+ * - bit 7 (0x80): a real bit (struct +0xa) that is default-set at init and
10091
+ * whose de-assertion itself triggers a state-change notification — best read
10092
+ * as a "sync-master active / handoff-in-progress" toggle. Also normally 0 in
10093
+ * steady state.
10043
10094
  */
10044
10095
  var StatusFlag;
10045
10096
  (function (StatusFlag) {
10097
+ /**
10098
+ * Degraded to BPM Sync: the player is still tracking the master's tempo, but
10099
+ * beat alignment was dropped after a pitch-bend / jog nudge. Firmware sources
10100
+ * this from BeatSyncMaster struct +4.
10101
+ */
10102
+ StatusFlag[StatusFlag["BpmSync"] = 2] = "BpmSync";
10046
10103
  StatusFlag[StatusFlag["OnAir"] = 8] = "OnAir";
10047
10104
  StatusFlag[StatusFlag["Sync"] = 16] = "Sync";
10048
10105
  StatusFlag[StatusFlag["Master"] = 32] = "Master";
@@ -10115,14 +10172,24 @@ function statusFromPacket(packet) {
10115
10172
  playState: packet[0x7b],
10116
10173
  isOnAir: (packet[0x89] & types_1.CDJStatus.StatusFlag.OnAir) !== 0,
10117
10174
  isSync: (packet[0x89] & types_1.CDJStatus.StatusFlag.Sync) !== 0,
10175
+ isBpmSync: (packet[0x89] & types_1.CDJStatus.StatusFlag.BpmSync) !== 0,
10118
10176
  isMaster: (packet[0x89] & types_1.CDJStatus.StatusFlag.Master) !== 0,
10119
10177
  isEmergencyMode: !!packet[0xba],
10120
10178
  trackBPM,
10121
- sliderPitch: calcPitch(packet.slice(0x8d, 0x8d + 3)),
10122
- effectivePitch: calcPitch(packet.slice(0x99, 0x99 + 3)),
10179
+ // The CDJ status packet carries four pitch values (firmware CDJ-3000 FW3.20,
10180
+ // sub_d7d610): Pitch1@0x8c and Pitch3@0xc0 are the *effective* pitch (in
10181
+ // effect / on the BPM display, from the fader or a synced master), while
10182
+ // Pitch2@0x98 and Pitch4@0xc4 track the *local fader* position. We expose
10183
+ // Pitch1 as `effectivePitch` and Pitch2 as `sliderPitch`.
10184
+ effectivePitch: calcPitch(packet.slice(0x8d, 0x8d + 3)),
10185
+ sliderPitch: calcPitch(packet.slice(0x99, 0x99 + 3)),
10123
10186
  beatInMeasure: packet[0xa6],
10124
10187
  beatsUntilCue,
10125
10188
  beat,
10189
+ // Player-type / capability byte (dysentery's "nx"). Guarded because the
10190
+ // length check above only guarantees 0xc8; older/short packets may not
10191
+ // reach 0xcc, in which case the field reads as 0 (version-gated to 0 anyway).
10192
+ deviceType: packet.length > 0xcc ? packet[0xcc] : 0,
10126
10193
  packetNum: packet.readUInt32BE(0xc8),
10127
10194
  };
10128
10195
  return status;
@@ -10867,11 +10934,17 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
10867
10934
  exports.DEFAULT_MIXER_PLAYER_CEILING = exports.MIXER_PLAYER_CEILING = exports.MAX_DEVICE_ID = exports.REMOTEDB_MAX_DEVICE_ID = void 0;
10868
10935
  exports.playerNumberCeiling = playerNumberCeiling;
10869
10936
  exports.pickAvailableDeviceId = pickAvailableDeviceId;
10937
+ exports.pickRemoteDbQueryId = pickRemoteDbQueryId;
10870
10938
  /**
10871
10939
  * The highest device ID a CDJ will answer remotedb queries from.
10872
10940
  *
10873
- * CDJs ignore metadata requests from devices numbered above this, which is why
10874
- * the 1-6 range is worth preferring even though real players live there too.
10941
+ * This limit applies to the device-ID byte inside the remotedb protocol (the
10942
+ * Introduce message and every query header), NOT to the ID we announce on the
10943
+ * network. Hardware-verified on a CDJ-3000 (2026-08-30, two players + DJM-V5,
10944
+ * announced as VCDJ 7 throughout): queries carrying 1-6 are all answered —
10945
+ * including IDs of absent players and the target's own ID — while 7 and above
10946
+ * are silently ignored (the TCP connection and Introduce succeed, the query
10947
+ * never gets a response). See {@link pickRemoteDbQueryId}.
10875
10948
  */
10876
10949
  exports.REMOTEDB_MAX_DEVICE_ID = 6;
10877
10950
  /**
@@ -10879,11 +10952,13 @@ exports.REMOTEDB_MAX_DEVICE_ID = 6;
10879
10952
  */
10880
10953
  exports.MAX_DEVICE_ID = 32;
10881
10954
  /**
10882
- * `DeviceType.Mixer`, inlined so this module stays free of runtime imports —
10883
- * it is re-exported into consumers' test environments, where pulling in the
10884
- * whole type module (and its ip-address dependency) is dead weight.
10955
+ * `DeviceType.Mixer` and `DeviceType.CDJ`, inlined so this module stays free
10956
+ * of runtime imports — it is re-exported into consumers' test environments,
10957
+ * where pulling in the whole type module (and its ip-address dependency) is
10958
+ * dead weight.
10885
10959
  */
10886
10960
  const MIXER_DEVICE_TYPE = 0x03;
10961
+ const CDJ_DEVICE_TYPE = 0x01;
10887
10962
  /**
10888
10963
  * How many player numbers each mixer can hand out.
10889
10964
  *
@@ -10982,24 +11057,233 @@ const ascending = (from, to) => {
10982
11057
  * Returns null when every ID from 1 to 32 is occupied, in which case there is
10983
11058
  * no safe ID and the caller must not join the network.
10984
11059
  */
10985
- function pickAvailableDeviceId(usedIds, { preferRemoteDb = false, playerCeiling } = {}) {
11060
+ function pickAvailableDeviceId(usedIds, { preferPlayerRange = false, playerCeiling } = {}) {
10986
11061
  var _a;
10987
11062
  const used = new Set(usedIds);
10988
11063
  const aboveThePlayers = playerCeiling === undefined
10989
11064
  ? descending(exports.REMOTEDB_MAX_DEVICE_ID, 1)
10990
11065
  : ascending(playerCeiling + 1, exports.REMOTEDB_MAX_DEVICE_ID);
10991
- const outsideRemoteDb = ascending(exports.REMOTEDB_MAX_DEVICE_ID + 1, exports.MAX_DEVICE_ID);
11066
+ const outsidePlayerRange = ascending(exports.REMOTEDB_MAX_DEVICE_ID + 1, exports.MAX_DEVICE_ID);
10992
11067
  // Numbers a player on this rig could be assigned. Last resort: better to
10993
11068
  // contend for a slot than not to join at all, and the announcer moves us if
10994
11069
  // the player that owns it shows up.
10995
11070
  const amongThePlayers = playerCeiling === undefined
10996
11071
  ? []
10997
11072
  : descending(Math.min(playerCeiling, exports.REMOTEDB_MAX_DEVICE_ID), 1);
10998
- const search = preferRemoteDb
10999
- ? [...aboveThePlayers, ...outsideRemoteDb, ...amongThePlayers]
11000
- : [...outsideRemoteDb, ...aboveThePlayers, ...amongThePlayers];
11073
+ const search = preferPlayerRange
11074
+ ? [...aboveThePlayers, ...outsidePlayerRange, ...amongThePlayers]
11075
+ : [...outsidePlayerRange, ...aboveThePlayers, ...amongThePlayers];
11001
11076
  return (_a = search.find(id => !used.has(id))) !== null && _a !== void 0 ? _a : null;
11002
11077
  }
11078
+ /**
11079
+ * Choose the device ID to carry inside remotedb messages when querying a CDJ.
11080
+ *
11081
+ * CDJs only answer remotedb queries whose in-protocol device-ID byte is in
11082
+ * 1-6, but that byte is independent of the ID we announce on the network — so
11083
+ * a virtual CDJ announced safely above the player range (say 7 behind a
11084
+ * DJM-V10) can still query metadata by carrying an in-range ID here. Verified
11085
+ * on a CDJ-3000 (2026-08-30): absent IDs and even the target's own ID are
11086
+ * answered; 7+ is silently dropped.
11087
+ *
11088
+ * Older players are documented (dysentery, nexus era) as stricter: the byte
11089
+ * had to be 1-4, belong to a player actually on the network, and not be the
11090
+ * player being queried. The preference order below satisfies those rules
11091
+ * whenever the rig makes it possible, then falls back through choices newer
11092
+ * players accept.
11093
+ */
11094
+ function pickRemoteDbQueryId(targetId, devices) {
11095
+ const playerIds = new Set();
11096
+ for (const device of devices) {
11097
+ if (device.type === CDJ_DEVICE_TYPE && device.id <= exports.REMOTEDB_MAX_DEVICE_ID) {
11098
+ playerIds.add(device.id);
11099
+ }
11100
+ }
11101
+ // A live player that isn't the target, lowest first — 1-4 satisfies every
11102
+ // documented era of the restriction.
11103
+ const otherPlayers = [...playerIds].filter(id => id !== targetId).sort((a, b) => a - b);
11104
+ if (otherPlayers.length > 0) {
11105
+ return otherPlayers[0];
11106
+ }
11107
+ // No other player on the rig: take the lowest 1-6 ID no player holds.
11108
+ for (let id = 1; id <= exports.REMOTEDB_MAX_DEVICE_ID; id++) {
11109
+ if (id !== targetId && !playerIds.has(id)) {
11110
+ return id;
11111
+ }
11112
+ }
11113
+ // Six players and every one of them is the target? Impossible, but the
11114
+ // target's own ID is answered too, so it is a safe closing fallback.
11115
+ return targetId;
11116
+ }
11117
+
11118
+
11119
+ /***/ },
11120
+
11121
+ /***/ "./src/virtualcdj/heartbeat.ts"
11122
+ /*!*************************************!*\
11123
+ !*** ./src/virtualcdj/heartbeat.ts ***!
11124
+ \*************************************/
11125
+ (__unused_webpack_module, exports, __webpack_require__) {
11126
+
11127
+ "use strict";
11128
+
11129
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
11130
+ if (kind === "m") throw new TypeError("Private method is not writable");
11131
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
11132
+ 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");
11133
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11134
+ };
11135
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
11136
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
11137
+ 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");
11138
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11139
+ };
11140
+ var _StagehandHeartbeat_instances, _StagehandHeartbeat_statusSocket, _StagehandHeartbeat_vcdj, _StagehandHeartbeat_deviceManager, _StagehandHeartbeat_logger, _StagehandHeartbeat_intervalHandle, _StagehandHeartbeat_sendHeartbeats;
11141
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
11142
+ exports.StagehandHeartbeat = exports.STAGEHAND_HEARTBEAT_INTERVAL = void 0;
11143
+ exports.makeStagehandMixerHeartbeat = makeStagehandMixerHeartbeat;
11144
+ exports.makeStagehandPlayerHeartbeat = makeStagehandPlayerHeartbeat;
11145
+ const constants_1 = __webpack_require__(/*! src/constants */ "./src/constants.ts");
11146
+ const logger_1 = __webpack_require__(/*! src/logger */ "./src/logger.ts");
11147
+ const types_1 = __webpack_require__(/*! src/types */ "./src/types.ts");
11148
+ /**
11149
+ * Cadence of the Stagehand unicast keep-alive, in milliseconds.
11150
+ *
11151
+ * The real iOS Stagehand app heartbeats every discovered player and the mixer
11152
+ * at ~4 Hz (250ms median inter-packet gap in captured traffic). This is the
11153
+ * stream that keeps AlphaTheta hardware unicasting live state (`0x39` mixer
11154
+ * fader/EQ, `0x58` VU on the mixer; `0x69` slim status, waveform families on
11155
+ * the CDJs) to our IP — subnet-broadcast presence alone (the `0x06` keep-alive)
11156
+ * is not enough to bootstrap it.
11157
+ */
11158
+ exports.STAGEHAND_HEARTBEAT_INTERVAL = 250;
11159
+ /**
11160
+ * Builds a Stagehand name field for a unicast frame.
11161
+ *
11162
+ * Unlike the 20-byte {@link buildName} used by the broadcast announce/claim
11163
+ * frames, unicast frames carry a 19-byte name field (offsets 11..29) with the
11164
+ * device type / model bytes beginning at offset 30. The name is ASCII, NUL
11165
+ * padded.
11166
+ */
11167
+ function buildUnicastName(name) {
11168
+ const field = new Uint8Array(19);
11169
+ field.set(Buffer.from(name, 'ascii').subarray(0, 19));
11170
+ return field;
11171
+ }
11172
+ /**
11173
+ * Build the Stagehand mixer keep-alive (`0x3a`, 40 bytes).
11174
+ *
11175
+ * Verified byte-for-byte against the real iOS Stagehand app unicasting to a
11176
+ * DJM-A9 at 4 Hz (Phase 4/5 captures §4.14.2). The 10-byte trailer
11177
+ * `21 02 00 fe 00 04 00 1c 00 00` is constant across every observed sample —
11178
+ * byte 33 (`0xfe`) is a VCDJ sentinel, not our runtime device number, so no
11179
+ * per-device bytes vary here.
11180
+ */
11181
+ function makeStagehandMixerHeartbeat(vcdj) {
11182
+ const parts = [
11183
+ ...constants_1.PROLINK_HEADER, // 0-9: magic
11184
+ 0x3a, // 10: type 0x3a (mixer keep-alive)
11185
+ ...buildUnicastName(vcdj.name), // 11-29: name (19 bytes)
11186
+ 0x21,
11187
+ 0x02,
11188
+ 0x00,
11189
+ 0xfe,
11190
+ 0x00,
11191
+ 0x04,
11192
+ 0x00,
11193
+ 0x1c,
11194
+ 0x00,
11195
+ 0x00, // 30-39: constant trailer
11196
+ ];
11197
+ return Uint8Array.from(parts);
11198
+ }
11199
+ /**
11200
+ * Build the Stagehand player keep-alive (`0x68`, 36 bytes).
11201
+ *
11202
+ * Verified byte-for-byte against the real iOS Stagehand app unicasting to a
11203
+ * CDJ-3000 at ~2-4 Hz. Body (offsets 30-35) is `03 01 00 3a 00 00`: `0x03`
11204
+ * persona/channel, `0x01` device subkind, `0x00` flag, `0x3a` Stagehand
11205
+ * model-code stamp, two reserved bytes. Constant across every observed sample.
11206
+ */
11207
+ function makeStagehandPlayerHeartbeat(vcdj) {
11208
+ const parts = [
11209
+ ...constants_1.PROLINK_HEADER, // 0-9: magic
11210
+ 0x68, // 10: type 0x68 (player keep-alive)
11211
+ ...buildUnicastName(vcdj.name), // 11-29: name (19 bytes)
11212
+ 0x03,
11213
+ 0x01,
11214
+ 0x00,
11215
+ 0x3a,
11216
+ 0x00,
11217
+ 0x00, // 30-35: constant body
11218
+ ];
11219
+ return Uint8Array.from(parts);
11220
+ }
11221
+ /**
11222
+ * Unicasts Stagehand keep-alive frames to every discovered player and mixer so
11223
+ * that AlphaTheta hardware begins (and keeps) pushing live state to our IP.
11224
+ *
11225
+ * The {@link StagehandAnnouncer} makes us *visible* on the network via subnet
11226
+ * broadcast; this heartbeat is what makes hardware actually *talk back*. It
11227
+ * targets the mixer with `0x3a` and each CDJ with `0x68`, mirroring the real
11228
+ * iOS Stagehand app. Frames are sent from the status socket (port 50002) to
11229
+ * each device's port 50002, which is where their unicast state replies land and
11230
+ * where {@link StatusEmitter} listens for them.
11231
+ */
11232
+ class StagehandHeartbeat {
11233
+ constructor(vcdj, statusSocket, deviceManager, logger = logger_1.noopLogger) {
11234
+ _StagehandHeartbeat_instances.add(this);
11235
+ _StagehandHeartbeat_statusSocket.set(this, void 0);
11236
+ _StagehandHeartbeat_vcdj.set(this, void 0);
11237
+ _StagehandHeartbeat_deviceManager.set(this, void 0);
11238
+ _StagehandHeartbeat_logger.set(this, void 0);
11239
+ _StagehandHeartbeat_intervalHandle.set(this, void 0);
11240
+ __classPrivateFieldSet(this, _StagehandHeartbeat_vcdj, vcdj, "f");
11241
+ __classPrivateFieldSet(this, _StagehandHeartbeat_statusSocket, statusSocket, "f");
11242
+ __classPrivateFieldSet(this, _StagehandHeartbeat_deviceManager, deviceManager, "f");
11243
+ __classPrivateFieldSet(this, _StagehandHeartbeat_logger, logger, "f");
11244
+ }
11245
+ start() {
11246
+ if (__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f") !== undefined) {
11247
+ return;
11248
+ }
11249
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").info('Starting Stagehand heartbeat (unicast keep-alive)');
11250
+ __classPrivateFieldGet(this, _StagehandHeartbeat_instances, "m", _StagehandHeartbeat_sendHeartbeats).call(this);
11251
+ __classPrivateFieldSet(this, _StagehandHeartbeat_intervalHandle, setInterval(() => __classPrivateFieldGet(this, _StagehandHeartbeat_instances, "m", _StagehandHeartbeat_sendHeartbeats).call(this), exports.STAGEHAND_HEARTBEAT_INTERVAL), "f");
11252
+ }
11253
+ stop() {
11254
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").info('Stopping Stagehand heartbeat');
11255
+ if (__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f") !== undefined) {
11256
+ clearInterval(__classPrivateFieldGet(this, _StagehandHeartbeat_intervalHandle, "f"));
11257
+ __classPrivateFieldSet(this, _StagehandHeartbeat_intervalHandle, undefined, "f");
11258
+ }
11259
+ }
11260
+ }
11261
+ exports.StagehandHeartbeat = StagehandHeartbeat;
11262
+ _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() {
11263
+ for (const device of __classPrivateFieldGet(this, _StagehandHeartbeat_deviceManager, "f").devices.values()) {
11264
+ // Never heartbeat ourselves.
11265
+ if (device.id === __classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f").id) {
11266
+ continue;
11267
+ }
11268
+ let packet = null;
11269
+ switch (device.type) {
11270
+ case types_1.DeviceType.Mixer:
11271
+ packet = makeStagehandMixerHeartbeat(__classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f"));
11272
+ break;
11273
+ case types_1.DeviceType.CDJ:
11274
+ packet = makeStagehandPlayerHeartbeat(__classPrivateFieldGet(this, _StagehandHeartbeat_vcdj, "f"));
11275
+ break;
11276
+ default:
11277
+ // Rekordbox / other Stagehand peers are not heartbeat targets.
11278
+ continue;
11279
+ }
11280
+ __classPrivateFieldGet(this, _StagehandHeartbeat_statusSocket, "f").send(packet, constants_1.STATUS_PORT, device.ip.address, err => {
11281
+ if (err) {
11282
+ __classPrivateFieldGet(this, _StagehandHeartbeat_logger, "f").debug(`Stagehand heartbeat to ${device.name} (${device.ip.address}) failed: ${err.message}`);
11283
+ }
11284
+ });
11285
+ }
11286
+ };
11003
11287
 
11004
11288
 
11005
11289
  /***/ },
@@ -11428,16 +11712,16 @@ _Announcer_announceSocket = new WeakMap(), _Announcer_deviceManager = new WeakMa
11428
11712
  }, _Announcer_handleConflict = function _Announcer_handleConflict() {
11429
11713
  // Stop announcing under the contested ID
11430
11714
  __classPrivateFieldGet(this, _Announcer_instances, "m", _Announcer_clearTimer).call(this);
11431
- // Stay in whichever range we were already in. Dropping out of 1-6 would
11432
- // silently lose remotedb metadata for unanalyzed and streaming tracks, so
11433
- // a conflict on player 5 looks for another free player number first.
11715
+ // Stay in whichever range we were already in, so a conflict on player 5
11716
+ // looks for another free player number first rather than jumping the
11717
+ // whole device out of the range the DJ configured it into.
11434
11718
  //
11435
11719
  // The mixer decides which of those numbers are actually safe: a player can
11436
11720
  // only be assigned a number its mixer has a channel for, so anything above
11437
11721
  // that is out of reach of the rig that just contested us.
11438
11722
  const devices = [...__classPrivateFieldGet(this, _Announcer_deviceManager, "f").devices.values()];
11439
11723
  const newId = (0, device_id_1.pickAvailableDeviceId)(devices.map(d => d.id), {
11440
- preferRemoteDb: __classPrivateFieldGet(this, _Announcer_vcdj, "f").id <= device_id_1.REMOTEDB_MAX_DEVICE_ID,
11724
+ preferPlayerRange: __classPrivateFieldGet(this, _Announcer_vcdj, "f").id <= device_id_1.REMOTEDB_MAX_DEVICE_ID,
11441
11725
  playerCeiling: (0, device_id_1.playerNumberCeiling)(devices),
11442
11726
  });
11443
11727
  if (newId === null) {
@@ -11540,6 +11824,7 @@ _Announcer_announceSocket = new WeakMap(), _Announcer_deviceManager = new WeakMa
11540
11824
  __classPrivateFieldSet(this, _Announcer_intervalHandle, setInterval(sendKeepAlive, constants_1.ANNOUNCE_INTERVAL), "f");
11541
11825
  };
11542
11826
  __exportStar(__webpack_require__(/*! ./device-id */ "./src/virtualcdj/device-id.ts"), exports);
11827
+ __exportStar(__webpack_require__(/*! ./heartbeat */ "./src/virtualcdj/heartbeat.ts"), exports);
11543
11828
  __exportStar(__webpack_require__(/*! ./stagehand */ "./src/virtualcdj/stagehand.ts"), exports);
11544
11829
 
11545
11830