@lox-audioserver/node-airplay-sender 0.4.4 → 0.4.5
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/README.md +2 -8
- package/dist/core/audioOut.d.ts +1 -14
- package/dist/core/audioOut.js +16 -152
- package/dist/core/deviceAirtunes.d.ts +2 -4
- package/dist/core/deviceAirtunes.js +18 -51
- package/dist/core/devices.js +2 -14
- package/dist/core/index.d.ts +3 -10
- package/dist/core/index.js +5 -13
- package/dist/core/rtsp.js +15 -51
- package/dist/core/udpServers.d.ts +1 -19
- package/dist/core/udpServers.js +3 -94
- package/dist/esm/core/audioOut.js +16 -152
- package/dist/esm/core/deviceAirtunes.js +18 -51
- package/dist/esm/core/devices.js +2 -14
- package/dist/esm/core/index.js +5 -13
- package/dist/esm/core/rtsp.js +15 -51
- package/dist/esm/core/udpServers.js +3 -94
- package/dist/esm/homekit/credentials.js +5 -15
- package/dist/esm/index.js +0 -5
- package/dist/esm/utils/circularBuffer.js +0 -8
- package/dist/esm/utils/config.js +2 -12
- package/dist/esm/utils/ntp.js +12 -41
- package/dist/homekit/credentials.d.ts +1 -2
- package/dist/homekit/credentials.js +5 -15
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -5
- package/dist/utils/circularBuffer.d.ts +0 -1
- package/dist/utils/circularBuffer.js +0 -8
- package/dist/utils/config.d.ts +0 -10
- package/dist/utils/config.js +2 -12
- package/dist/utils/ntp.d.ts +1 -15
- package/dist/utils/ntp.js +12 -41
- package/package.json +1 -1
|
@@ -108,12 +108,10 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
108
108
|
if (!host) {
|
|
109
109
|
throw new Error('host is mandatory');
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
this.audioPacketHistory = new BufferWithNames(Number.isFinite(resendBufferSize) && resendBufferSize > 0 ? resendBufferSize : 1000);
|
|
111
|
+
this.audioPacketHistory = new BufferWithNames(100);
|
|
113
112
|
this.udpServers = new udpServers_1.default();
|
|
114
113
|
this.audioOut = audioOut;
|
|
115
114
|
this.type = 'airtunes';
|
|
116
|
-
this.deviceMagic = Number(options?.deviceMagic ?? config_1.default.device_magic);
|
|
117
115
|
this.options = options;
|
|
118
116
|
this.log = options?.log;
|
|
119
117
|
this.logLine = (...args) => logLine(this, ...args);
|
|
@@ -133,7 +131,6 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
133
131
|
this.airplay2 = options?.airplay2 ?? false;
|
|
134
132
|
this.txt = Array.isArray(txt) ? txt : txt ? [String(txt)] : [];
|
|
135
133
|
this.borkedshp = false;
|
|
136
|
-
this.transient = false;
|
|
137
134
|
if (this.airplay2 && this.mode === 0) {
|
|
138
135
|
this.mode = 2;
|
|
139
136
|
}
|
|
@@ -156,8 +153,7 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
156
153
|
}
|
|
157
154
|
this.needPassword = false;
|
|
158
155
|
this.needPin = false;
|
|
159
|
-
|
|
160
|
-
this.transient = typeof transientOption === 'boolean' ? transientOption : false;
|
|
156
|
+
this.transient = false;
|
|
161
157
|
let d = this.txt.filter((u) => String(u).startsWith('features='));
|
|
162
158
|
if (d.length === 0)
|
|
163
159
|
d = this.txt.filter((u) => String(u).startsWith('ft='));
|
|
@@ -167,7 +163,7 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
167
163
|
...(features_set.length > 1 ? parseInt(features_set[1], 10).toString(2).split('') : []),
|
|
168
164
|
];
|
|
169
165
|
if (this.features.length > 0) {
|
|
170
|
-
this.transient = this.features[this.features.length - 1 - 48]
|
|
166
|
+
this.transient = (this.features[this.features.length - 1 - 48] == '1');
|
|
171
167
|
}
|
|
172
168
|
if (this.statusflags.length) {
|
|
173
169
|
let PasswordRequired = (this.statusflags[this.statusflags.length - 1 - 7] == '1');
|
|
@@ -228,14 +224,6 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
228
224
|
this.audioCallback = null;
|
|
229
225
|
this.encoder = [];
|
|
230
226
|
this.credentials = null;
|
|
231
|
-
this.onUnderrun = () => {
|
|
232
|
-
this.encoder = [];
|
|
233
|
-
this.logLine?.('underrun_encoder_reset');
|
|
234
|
-
if (this.credentials) {
|
|
235
|
-
this.credentials.encryptCount = 0;
|
|
236
|
-
this.credentials.decryptCount = 0;
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
227
|
// this.func = `
|
|
240
228
|
// const {Worker, isMainThread, parentPort, workerData} = require('node:worker_threads');
|
|
241
229
|
// var { WebSocketServer } = require('ws');
|
|
@@ -309,7 +297,8 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
309
297
|
this.rtsp.on('ready', () => {
|
|
310
298
|
this.status = 'playing';
|
|
311
299
|
this.emit('status', 'playing');
|
|
312
|
-
this.
|
|
300
|
+
if (this.airplay2)
|
|
301
|
+
this.relayAudio();
|
|
313
302
|
});
|
|
314
303
|
this.rtsp.on('need_password', () => {
|
|
315
304
|
this.emit('status', 'need_password');
|
|
@@ -337,17 +326,10 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
337
326
|
this.rtsp.startHandshake(this.udpServers, this.host, this.port);
|
|
338
327
|
};
|
|
339
328
|
AirTunesDevice.prototype.relayAudio = function () {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
329
|
+
this.status = 'ready';
|
|
330
|
+
this.emit('status', 'ready');
|
|
343
331
|
this.audioCallback = (packet) => {
|
|
344
|
-
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec
|
|
345
|
-
try {
|
|
346
|
-
this.audioPacketHistory?.add(packet.seq, airTunes);
|
|
347
|
-
}
|
|
348
|
-
catch {
|
|
349
|
-
/* ignore history errors */
|
|
350
|
-
}
|
|
332
|
+
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec);
|
|
351
333
|
// if (self.credentials) {
|
|
352
334
|
// airTunes = self.credentials.encrypt(airTunes)
|
|
353
335
|
// }
|
|
@@ -383,24 +365,10 @@ AirTunesDevice.prototype.relayAudio = function () {
|
|
|
383
365
|
// self.sendAirTunesPacket(airTunes);}}
|
|
384
366
|
// catch (_){}
|
|
385
367
|
// });
|
|
386
|
-
this.udpServers.on('resendRequested', (missedSeq, count) => {
|
|
387
|
-
for (let i = 0; i < count; i += 1) {
|
|
388
|
-
const seq = missedSeq + i;
|
|
389
|
-
try {
|
|
390
|
-
const pkt = this.audioPacketHistory?.getLatestNamed(seq);
|
|
391
|
-
if (pkt && this.audioSocket) {
|
|
392
|
-
this.audioSocket.send(pkt, 0, pkt.length, this.serverPort, this.host);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
// ignore resend errors
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
368
|
this.audioOut.on('packet', this.audioCallback);
|
|
401
369
|
};
|
|
402
|
-
AirTunesDevice.prototype.onSyncNeeded = function (seq
|
|
403
|
-
this.udpServers.sendControlSync(seq, this
|
|
370
|
+
AirTunesDevice.prototype.onSyncNeeded = function (seq) {
|
|
371
|
+
this.udpServers.sendControlSync(seq, this);
|
|
404
372
|
//if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
|
|
405
373
|
};
|
|
406
374
|
AirTunesDevice.prototype.cleanup = function () {
|
|
@@ -413,7 +381,6 @@ AirTunesDevice.prototype.cleanup = function () {
|
|
|
413
381
|
this.audioOut.removeListener('packet', this.audioCallback);
|
|
414
382
|
this.audioCallback = null;
|
|
415
383
|
}
|
|
416
|
-
this.encoder = [];
|
|
417
384
|
this.udpServers.close();
|
|
418
385
|
this.removeAllListeners();
|
|
419
386
|
this.rtsp = null;
|
|
@@ -423,11 +390,8 @@ AirTunesDevice.prototype.reportStatus = function () {
|
|
|
423
390
|
};
|
|
424
391
|
AirTunesDevice.prototype.stop = function (cb) {
|
|
425
392
|
try {
|
|
426
|
-
if (!this.rtsp)
|
|
427
|
-
if (cb)
|
|
428
|
-
cb();
|
|
393
|
+
if (!this.rtsp)
|
|
429
394
|
return;
|
|
430
|
-
}
|
|
431
395
|
this.rtsp.once('end', function () {
|
|
432
396
|
if (cb)
|
|
433
397
|
cb();
|
|
@@ -462,8 +426,11 @@ AirTunesDevice.prototype.setPasscode = function (password) {
|
|
|
462
426
|
return;
|
|
463
427
|
this.rtsp.setPasscode(password);
|
|
464
428
|
};
|
|
429
|
+
AirTunesDevice.prototype.requireEncryption = function () {
|
|
430
|
+
return Boolean(this.requireEncryption);
|
|
431
|
+
};
|
|
465
432
|
exports.default = AirTunesDevice;
|
|
466
|
-
function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = true, credentials = null, inputCodec = 'pcm'
|
|
433
|
+
function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = true, credentials = null, inputCodec = 'pcm') {
|
|
467
434
|
const useAlacInput = inputCodec === 'alac';
|
|
468
435
|
var alac = useAlacInput
|
|
469
436
|
? packet.pcm
|
|
@@ -471,7 +438,7 @@ function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = t
|
|
|
471
438
|
? (0, alac_1.encodePcmToAlac)(packet.pcm)
|
|
472
439
|
: pcmParse(packet.pcm);
|
|
473
440
|
var airTunes = Buffer.alloc(alac.length + RTP_HEADER_SIZE);
|
|
474
|
-
var header = makeRTPHeader(packet
|
|
441
|
+
var header = makeRTPHeader(packet);
|
|
475
442
|
if (requireEncryption) {
|
|
476
443
|
alac = encryptAES(alac, alac.length);
|
|
477
444
|
}
|
|
@@ -521,7 +488,7 @@ function encryptAES(alacData, alacSize) {
|
|
|
521
488
|
}
|
|
522
489
|
return Buffer.concat([result, alacData.slice(end_of_encoded_data)]);
|
|
523
490
|
}
|
|
524
|
-
function makeRTPHeader(packet
|
|
491
|
+
function makeRTPHeader(packet) {
|
|
525
492
|
var header = Buffer.alloc(RTP_HEADER_SIZE);
|
|
526
493
|
if (packet.seq === 0)
|
|
527
494
|
header.writeUInt16BE(0x80e0, 0);
|
|
@@ -529,6 +496,6 @@ function makeRTPHeader(packet, deviceMagic) {
|
|
|
529
496
|
header.writeUInt16BE(0x8060, 0);
|
|
530
497
|
header.writeUInt16BE(nu.low16(packet.seq), 2);
|
|
531
498
|
header.writeUInt32BE(packet.timestamp, 4);
|
|
532
|
-
header.writeUInt32BE(
|
|
499
|
+
header.writeUInt32BE(config_1.default.device_magic, 8);
|
|
533
500
|
return header;
|
|
534
501
|
}
|
package/dist/esm/core/devices.js
CHANGED
|
@@ -20,11 +20,11 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
20
20
|
}
|
|
21
21
|
/** Wire device sync events from AudioOut into individual devices. */
|
|
22
22
|
init() {
|
|
23
|
-
this.audioOut.on('need_sync', (
|
|
23
|
+
this.audioOut.on('need_sync', (seq) => {
|
|
24
24
|
this.forEach((dev) => {
|
|
25
25
|
try {
|
|
26
26
|
if (dev.onSyncNeeded && dev.controlPort) {
|
|
27
|
-
dev.onSyncNeeded(
|
|
27
|
+
dev.onSyncNeeded(seq);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
catch {
|
|
@@ -32,18 +32,6 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
-
this.audioOut.on('underrun', () => {
|
|
36
|
-
this.forEach((dev) => {
|
|
37
|
-
try {
|
|
38
|
-
if (dev.onUnderrun) {
|
|
39
|
-
dev.onUnderrun();
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* ignore */
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
35
|
}
|
|
48
36
|
/** Iterate over live devices. */
|
|
49
37
|
forEach(it) {
|
package/dist/esm/core/index.js
CHANGED
|
@@ -40,8 +40,6 @@ const node_stream_1 = require("node:stream");
|
|
|
40
40
|
const devices_1 = __importDefault(require("./devices"));
|
|
41
41
|
const config_1 = __importStar(require("../utils/config"));
|
|
42
42
|
const circularBuffer_1 = __importDefault(require("../utils/circularBuffer"));
|
|
43
|
-
const ntp_1 = require("../utils/ntp");
|
|
44
|
-
const numUtil_1 = require("../utils/numUtil");
|
|
45
43
|
const audioOut_1 = __importDefault(require("./audioOut"));
|
|
46
44
|
/**
|
|
47
45
|
* High-level RAOP/AirPlay sender that wires together devices, buffering, and output.
|
|
@@ -50,33 +48,27 @@ const audioOut_1 = __importDefault(require("./audioOut"));
|
|
|
50
48
|
class AirTunes extends node_stream_1.Duplex {
|
|
51
49
|
devices;
|
|
52
50
|
circularBuffer;
|
|
53
|
-
deviceMagic;
|
|
54
51
|
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*/
|
|
52
|
+
* @param options.packetSize Override packet size; defaults to config.
|
|
53
|
+
* @param options.startTimeMs Optional unix ms to align playback start.
|
|
54
|
+
*/
|
|
59
55
|
constructor(options = {}) {
|
|
60
56
|
super({ readableObjectMode: false, writableObjectMode: false });
|
|
61
57
|
if (options.config) {
|
|
62
58
|
(0, config_1.applyConfig)(options.config);
|
|
63
59
|
}
|
|
64
|
-
// Randomize per-session device magic (SSRC equivalent) to align with reference behavior.
|
|
65
|
-
const deviceMagic = typeof options.deviceMagic === 'number' ? options.deviceMagic : (0, numUtil_1.randomInt)(9);
|
|
66
60
|
const audioOut = new audioOut_1.default();
|
|
67
|
-
this.deviceMagic = deviceMagic;
|
|
68
61
|
this.devices = new devices_1.default(audioOut);
|
|
69
62
|
this.devices.init();
|
|
70
63
|
this.devices.on('status', (key, status, desc) => {
|
|
71
64
|
this.emit('device', key, status, desc);
|
|
72
65
|
});
|
|
73
66
|
const packetSize = options.packetSize ?? config_1.default.packet_size;
|
|
74
|
-
const startTimeNtp = options.startTimeNtp ? (0, ntp_1.toNtpTimestamp)(options.startTimeNtp) : undefined;
|
|
75
67
|
this.circularBuffer = new circularBuffer_1.default(config_1.default.packets_in_buffer, packetSize);
|
|
76
68
|
this.circularBuffer.on('status', (status) => {
|
|
77
69
|
this.emit('buffer', status);
|
|
78
70
|
});
|
|
79
|
-
audioOut.init(this.devices, this.circularBuffer, options.startTimeMs
|
|
71
|
+
audioOut.init(this.devices, this.circularBuffer, options.startTimeMs);
|
|
80
72
|
audioOut.on('metrics', (metrics) => {
|
|
81
73
|
this.emit('metrics', metrics);
|
|
82
74
|
});
|
|
@@ -89,7 +81,7 @@ class AirTunes extends node_stream_1.Duplex {
|
|
|
89
81
|
}
|
|
90
82
|
/** Register an AirTunes (RAOP) device and start streaming to it. */
|
|
91
83
|
add(host, options, mode = 0, txt = '') {
|
|
92
|
-
return this.devices.add('airtunes', host,
|
|
84
|
+
return this.devices.add('airtunes', host, options, mode, txt);
|
|
93
85
|
}
|
|
94
86
|
/** Register a CoreAudio output (legacy shim). */
|
|
95
87
|
addCoreAudio(options) {
|
package/dist/esm/core/rtsp.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Client = Client;
|
|
4
|
-
// @ts-nocheck
|
|
5
4
|
var net = require('net'), nodeCrypto = require('crypto'), events = require('events'), util = require('util'), fs = require('fs'), dgram = require('dgram');
|
|
6
5
|
const ntp = require('../utils/ntp').default ?? require('../utils/ntp');
|
|
7
6
|
const config = require('../utils/config').default ?? require('../utils/config');
|
|
@@ -93,6 +92,7 @@ function Client(volume, password, audioOut, options) {
|
|
|
93
92
|
this.activeRemote = nu.randomInt(9).toString().toUpperCase();
|
|
94
93
|
this.dacpId = "04F8191D99BEC6E9";
|
|
95
94
|
this.session = null;
|
|
95
|
+
this.readySent = false;
|
|
96
96
|
this.timeout = null;
|
|
97
97
|
this.volume = volume;
|
|
98
98
|
this.progress = 0;
|
|
@@ -152,7 +152,6 @@ function Client(volume, password, audioOut, options) {
|
|
|
152
152
|
this.homekitver = this.transient ? "4" : "3";
|
|
153
153
|
this.metadataReady = false;
|
|
154
154
|
this.connectAttempts = 0;
|
|
155
|
-
this.lastBackoff = 0;
|
|
156
155
|
}
|
|
157
156
|
util.inherits(Client, events.EventEmitter);
|
|
158
157
|
exports.default = { Client };
|
|
@@ -222,8 +221,7 @@ Client.prototype.startHandshake = function (udpServers, host, port) {
|
|
|
222
221
|
const baseBackOff = Math.min(config.rtsp_retry_base_ms * Math.pow(2, nextAttempt - 1), config.rtsp_retry_max_ms);
|
|
223
222
|
const jitter = Math.random() * config.rtsp_retry_jitter_ms;
|
|
224
223
|
const backOff = baseBackOff + jitter;
|
|
225
|
-
this.
|
|
226
|
-
if (this.debug || config.debug_dump)
|
|
224
|
+
if (this.debug)
|
|
227
225
|
this.logLine?.('rtsp_retry', { attempt: nextAttempt, backOff, code: err?.code });
|
|
228
226
|
setTimeout(() => {
|
|
229
227
|
this.startTimeout();
|
|
@@ -242,14 +240,8 @@ Client.prototype.startHandshake = function (udpServers, host, port) {
|
|
|
242
240
|
this.cleanup('rtsp_socket', err?.code);
|
|
243
241
|
});
|
|
244
242
|
this.socket.on('end', () => {
|
|
245
|
-
if (this.debug)
|
|
246
|
-
this.logLine?.('
|
|
247
|
-
lastStatus: rtsp_methods[this.status + 1] ?? this.status,
|
|
248
|
-
cseq: this.cseq,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
this.connectAttempts = 0;
|
|
252
|
-
this.lastBackoff = 0;
|
|
243
|
+
if (this.debug)
|
|
244
|
+
this.logLine?.('block2');
|
|
253
245
|
this.cleanup('disconnected');
|
|
254
246
|
});
|
|
255
247
|
};
|
|
@@ -289,13 +281,6 @@ Client.prototype.setVolume = function (volume, callback) {
|
|
|
289
281
|
this.sendNextRequest();
|
|
290
282
|
};
|
|
291
283
|
Client.prototype.setProgress = function (progress, duration, callback) {
|
|
292
|
-
if (this.airplay2) {
|
|
293
|
-
this.progress = progress;
|
|
294
|
-
this.duration = duration;
|
|
295
|
-
if (typeof callback === 'function')
|
|
296
|
-
callback();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
284
|
if (this.status !== PLAYING)
|
|
300
285
|
return;
|
|
301
286
|
let normProgress = progress;
|
|
@@ -341,12 +326,6 @@ Client.prototype.sendHeartBeat = function (callback) {
|
|
|
341
326
|
this.sendNextRequest();
|
|
342
327
|
};
|
|
343
328
|
Client.prototype.setTrackInfo = function (name, artist, album, callback) {
|
|
344
|
-
if (this.airplay2) {
|
|
345
|
-
this.trackInfo = { name, artist, album };
|
|
346
|
-
if (typeof callback === 'function')
|
|
347
|
-
callback();
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
329
|
if (this.status !== PLAYING)
|
|
351
330
|
return;
|
|
352
331
|
if (name != this.trackInfo?.name || artist != this.trackInfo?.artist || album != this.trackInfo?.album) {
|
|
@@ -424,7 +403,6 @@ Client.prototype.cleanup = function (type, msg) {
|
|
|
424
403
|
this.seed = null;
|
|
425
404
|
this.credentials = null;
|
|
426
405
|
// this.password = null;
|
|
427
|
-
this.connectAttempts = 0;
|
|
428
406
|
this.removeAllListeners();
|
|
429
407
|
if (this.timeout) {
|
|
430
408
|
clearTimeout(this.timeout);
|
|
@@ -840,9 +818,6 @@ Client.prototype.sendNextRequest = async function (di) {
|
|
|
840
818
|
request += 'Connection: keep-alive\r\n';
|
|
841
819
|
}
|
|
842
820
|
request += 'Apple-Challenge: SdX9kFJVxgKVMFof/Znj4Q\r\n\r\n';
|
|
843
|
-
this.socket.write(Buffer.from(request, 'utf-8'));
|
|
844
|
-
request = '';
|
|
845
|
-
this.status = PLAYING;
|
|
846
821
|
break;
|
|
847
822
|
case OPTIONS2:
|
|
848
823
|
request = '';
|
|
@@ -1089,10 +1064,6 @@ Client.prototype.sendNextRequest = async function (di) {
|
|
|
1089
1064
|
//this.logLine?.(request);
|
|
1090
1065
|
break;
|
|
1091
1066
|
case SETPROGRESS:
|
|
1092
|
-
if (this.airplay2) {
|
|
1093
|
-
this.status = PLAYING;
|
|
1094
|
-
break;
|
|
1095
|
-
}
|
|
1096
1067
|
function hms(seconds) {
|
|
1097
1068
|
const h = Math.floor(seconds / 3600);
|
|
1098
1069
|
const m = Math.floor((seconds % 3600) / 60);
|
|
@@ -1113,10 +1084,6 @@ Client.prototype.sendNextRequest = async function (di) {
|
|
|
1113
1084
|
//this.logLine?.(request);
|
|
1114
1085
|
break;
|
|
1115
1086
|
case SETDAAP:
|
|
1116
|
-
if (this.airplay2) {
|
|
1117
|
-
this.status = PLAYING;
|
|
1118
|
-
break;
|
|
1119
|
-
}
|
|
1120
1087
|
let daapenc = true;
|
|
1121
1088
|
//daapenc = true
|
|
1122
1089
|
var name = this.daapEncode('minm', this.trackInfo.name, daapenc);
|
|
@@ -1165,10 +1132,6 @@ Client.prototype.sendNextRequest = async function (di) {
|
|
|
1165
1132
|
return;
|
|
1166
1133
|
}
|
|
1167
1134
|
this.startTimeout();
|
|
1168
|
-
if (config.debug_dump) {
|
|
1169
|
-
// eslint-disable-next-line no-console
|
|
1170
|
-
console.debug('rtsp_req', { status: rtsp_methods[this.status] ?? this.status, len: request.length });
|
|
1171
|
-
}
|
|
1172
1135
|
if (this.encryptedChannel && this.credentials) {
|
|
1173
1136
|
this.socket.write(this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8')])));
|
|
1174
1137
|
}
|
|
@@ -1225,10 +1188,6 @@ function parseAuthenticate(auth, field) {
|
|
|
1225
1188
|
Client.prototype.processData = function (blob, rawData) {
|
|
1226
1189
|
this.logLine?.('Receiving request:', this.hostip, rtsp_methods[this.status + 1]);
|
|
1227
1190
|
var response = parseResponse2(blob, this), headers = response.headers || {};
|
|
1228
|
-
if (config.debug_dump) {
|
|
1229
|
-
// eslint-disable-next-line no-console
|
|
1230
|
-
console.debug('rtsp_res', { code: response.code, status: rtsp_methods[this.status + 1], len: rawData.length });
|
|
1231
|
-
}
|
|
1232
1191
|
if (this.debug) {
|
|
1233
1192
|
try {
|
|
1234
1193
|
if ((rawData.toString()).includes("bplist00")) {
|
|
@@ -1469,9 +1428,7 @@ Client.prototype.processData = function (blob, rawData) {
|
|
|
1469
1428
|
return;
|
|
1470
1429
|
}
|
|
1471
1430
|
// this.logLine?.("DEBUG: Device Proof=" + this.deviceProof.toString('hex'));
|
|
1472
|
-
|
|
1473
|
-
this.srp.checkM2(this.deviceProof);
|
|
1474
|
-
}
|
|
1431
|
+
this.srp.checkM2(this.deviceProof);
|
|
1475
1432
|
if (this.transient == true) {
|
|
1476
1433
|
this.credentials = new Credentials("sdsds", "", "", "", this.seed);
|
|
1477
1434
|
this.credentials.writeKey = enc.HKDF("sha512", Buffer.from("Control-Salt"), this.srp.computeK(), Buffer.from("Control-Write-Encryption-Key"), 32);
|
|
@@ -1660,11 +1617,18 @@ Client.prototype.processData = function (blob, rawData) {
|
|
|
1660
1617
|
case RECORD:
|
|
1661
1618
|
this.metadataReady = true;
|
|
1662
1619
|
this.emit('pair_success');
|
|
1663
|
-
if (
|
|
1620
|
+
if (this.airplay2) {
|
|
1621
|
+
// AirPlay2 may not send FLUSH after SETPROGRESS; ensure session exists and start relay once.
|
|
1622
|
+
this.session = this.session ?? "1";
|
|
1623
|
+
if (!this.readySent) {
|
|
1624
|
+
this.readySent = true;
|
|
1625
|
+
this.emit('ready');
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
else {
|
|
1664
1629
|
this.session = this.session ?? "1";
|
|
1665
1630
|
this.emit('ready');
|
|
1666
1631
|
}
|
|
1667
|
-
;
|
|
1668
1632
|
this.status = SETVOLUME;
|
|
1669
1633
|
break;
|
|
1670
1634
|
case SETVOLUME:
|
|
@@ -1685,7 +1649,7 @@ Client.prototype.processData = function (blob, rawData) {
|
|
|
1685
1649
|
}
|
|
1686
1650
|
break;
|
|
1687
1651
|
case SETPROGRESS:
|
|
1688
|
-
//
|
|
1652
|
+
// Keep PLAYING to avoid forcing FLUSH on every progress update.
|
|
1689
1653
|
this.status = PLAYING;
|
|
1690
1654
|
break;
|
|
1691
1655
|
case SETDAAP:
|
|
@@ -133,112 +133,21 @@ class UDPServers extends node_events_1.EventEmitter {
|
|
|
133
133
|
/**
|
|
134
134
|
* Send an RTCP sync packet to a receiver to align playback.
|
|
135
135
|
*/
|
|
136
|
-
sendControlSync(seq, dev
|
|
136
|
+
sendControlSync(seq, dev) {
|
|
137
137
|
if (this.status !== BOUND || !this.control.socket)
|
|
138
138
|
return;
|
|
139
139
|
const packet = Buffer.alloc(20);
|
|
140
140
|
packet.writeUInt16BE(0x80d4, 0);
|
|
141
141
|
packet.writeUInt16BE(0x0007, 2);
|
|
142
|
-
packet.writeUInt32BE((0, numUtil_1.low32)(
|
|
142
|
+
packet.writeUInt32BE((0, numUtil_1.low32)(seq * config_1.default.frames_per_packet), 4);
|
|
143
143
|
const ntpTime = ntp_1.default.timestamp();
|
|
144
144
|
ntpTime.copy(packet, 8);
|
|
145
|
-
packet.writeUInt32BE((0, numUtil_1.low32)(
|
|
145
|
+
packet.writeUInt32BE((0, numUtil_1.low32)(seq * config_1.default.frames_per_packet + config_1.default.sampling_rate * 2), 16);
|
|
146
146
|
const delay = Math.max(0, config_1.default.control_sync_base_delay_ms +
|
|
147
147
|
Math.random() * config_1.default.control_sync_jitter_ms);
|
|
148
148
|
setTimeout(() => {
|
|
149
|
-
if (config_1.default.debug_dump) {
|
|
150
|
-
// eslint-disable-next-line no-console
|
|
151
|
-
console.debug('rtcp_sync', { seq, tsOffsetFrames, host: dev.host, port: dev.controlPort });
|
|
152
|
-
}
|
|
153
149
|
this.control.socket?.send(packet, 0, packet.length, dev.controlPort, dev.host);
|
|
154
|
-
if (sr && config_1.default.send_rtcp_sr) {
|
|
155
|
-
const srPacket = this.buildSenderReport(sr);
|
|
156
|
-
if (config_1.default.debug_dump) {
|
|
157
|
-
// eslint-disable-next-line no-console
|
|
158
|
-
console.debug('rtcp_sr', { ssrc: config_1.default.device_magic, rtp: sr.rtpTimestamp, packets: sr.packetCount });
|
|
159
|
-
}
|
|
160
|
-
this.control.socket?.send(srPacket, 0, srPacket.length, dev.controlPort, dev.host);
|
|
161
|
-
}
|
|
162
|
-
if (config_1.default.send_rtcp_rr) {
|
|
163
|
-
const rrPacket = this.buildReceiverReport(rr);
|
|
164
|
-
if (config_1.default.debug_dump) {
|
|
165
|
-
// eslint-disable-next-line no-console
|
|
166
|
-
console.debug('rtcp_rr', { ssrc: config_1.default.device_magic });
|
|
167
|
-
}
|
|
168
|
-
this.control.socket?.send(rrPacket, 0, rrPacket.length, dev.controlPort, dev.host);
|
|
169
|
-
}
|
|
170
|
-
if (xr && config_1.default.send_rtcp_xr) {
|
|
171
|
-
const xrPacket = this.buildExtendedReport(xr);
|
|
172
|
-
if (config_1.default.debug_dump) {
|
|
173
|
-
// eslint-disable-next-line no-console
|
|
174
|
-
console.debug('rtcp_xr', { ssrc: config_1.default.device_magic });
|
|
175
|
-
}
|
|
176
|
-
this.control.socket?.send(xrPacket, 0, xrPacket.length, dev.controlPort, dev.host);
|
|
177
|
-
}
|
|
178
150
|
}, delay);
|
|
179
151
|
}
|
|
180
|
-
buildSenderReport(counters) {
|
|
181
|
-
const sr = Buffer.alloc(28);
|
|
182
|
-
// V=2, P=0, RC=0
|
|
183
|
-
sr.writeUInt8(0x80, 0);
|
|
184
|
-
// PT=200 (SR)
|
|
185
|
-
sr.writeUInt8(200, 1);
|
|
186
|
-
// length in 32-bit words minus 1 -> 6 words (28 bytes) => 6
|
|
187
|
-
sr.writeUInt16BE(6, 2);
|
|
188
|
-
// SSRC
|
|
189
|
-
sr.writeUInt32BE(config_1.default.device_magic, 4);
|
|
190
|
-
// NTP timestamp
|
|
191
|
-
counters.ntp.copy(sr, 8, 0, 8);
|
|
192
|
-
// RTP timestamp
|
|
193
|
-
sr.writeUInt32BE((0, numUtil_1.low32)(counters.rtpTimestamp), 16);
|
|
194
|
-
// packet count
|
|
195
|
-
sr.writeUInt32BE((0, numUtil_1.low32)(counters.packetCount), 20);
|
|
196
|
-
// octet count
|
|
197
|
-
sr.writeUInt32BE((0, numUtil_1.low32)(counters.octetCount), 24);
|
|
198
|
-
return sr;
|
|
199
|
-
}
|
|
200
|
-
buildReceiverReport(rr) {
|
|
201
|
-
const packet = Buffer.alloc(8);
|
|
202
|
-
// V=2, P=0, RC=0
|
|
203
|
-
packet.writeUInt8(0x80, 0);
|
|
204
|
-
// PT=201 (RR)
|
|
205
|
-
packet.writeUInt8(201, 1);
|
|
206
|
-
// length = 1 (8 bytes / 4 - 1)
|
|
207
|
-
packet.writeUInt16BE(1, 2);
|
|
208
|
-
// SSRC
|
|
209
|
-
packet.writeUInt32BE(rr?.ssrc ?? config_1.default.device_magic, 4);
|
|
210
|
-
return packet;
|
|
211
|
-
}
|
|
212
|
-
buildExtendedReport(xr) {
|
|
213
|
-
// XR with RRT (Receiver Reference Time) and optional DLRR.
|
|
214
|
-
const rrtBlock = Buffer.alloc(12);
|
|
215
|
-
// BT=4 (RRT), reserved+block length=2 (8 octets following header)
|
|
216
|
-
rrtBlock.writeUInt8(4, 0);
|
|
217
|
-
rrtBlock.writeUInt8(0, 1);
|
|
218
|
-
rrtBlock.writeUInt16BE(2, 2);
|
|
219
|
-
(xr?.ntp ?? ntp_1.default.timestamp()).copy(rrtBlock, 4, 0, 8);
|
|
220
|
-
let dlrrBlock = null;
|
|
221
|
-
if (typeof xr?.lastRr === 'number' || typeof xr?.delaySinceLastRr === 'number') {
|
|
222
|
-
dlrrBlock = Buffer.alloc(12);
|
|
223
|
-
// BT=5 (DLRR)
|
|
224
|
-
dlrrBlock.writeUInt8(5, 0);
|
|
225
|
-
dlrrBlock.writeUInt8(0, 1);
|
|
226
|
-
dlrrBlock.writeUInt16BE(3, 2); // block length (3 words = 12 bytes following header)
|
|
227
|
-
dlrrBlock.writeUInt32BE(xr?.lastRr ?? 0, 4);
|
|
228
|
-
dlrrBlock.writeUInt32BE(xr?.delaySinceLastRr ?? 0, 8);
|
|
229
|
-
}
|
|
230
|
-
const blocks = dlrrBlock ? Buffer.concat([rrtBlock, dlrrBlock]) : rrtBlock;
|
|
231
|
-
const packet = Buffer.alloc(8 + blocks.length);
|
|
232
|
-
// V=2, P=0, RC=0
|
|
233
|
-
packet.writeUInt8(0x80, 0);
|
|
234
|
-
// PT=207 (XR)
|
|
235
|
-
packet.writeUInt8(207, 1);
|
|
236
|
-
// length in 32-bit words minus 1
|
|
237
|
-
packet.writeUInt16BE(packet.length / 4 - 1, 2);
|
|
238
|
-
// SSRC
|
|
239
|
-
packet.writeUInt32BE(xr?.ssrc ?? config_1.default.device_magic, 4);
|
|
240
|
-
blocks.copy(packet, 8);
|
|
241
|
-
return packet;
|
|
242
|
-
}
|
|
243
152
|
}
|
|
244
153
|
exports.default = UDPServers;
|
|
@@ -90,21 +90,11 @@ class Credentials {
|
|
|
90
90
|
}
|
|
91
91
|
return result;
|
|
92
92
|
}
|
|
93
|
-
encryptAudio(message, aad,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return Buffer.concat([cipher, tag, nonce.slice(4)]);
|
|
99
|
-
}
|
|
100
|
-
rotateKeys() {
|
|
101
|
-
const info = Buffer.from('AirPlay:Audio', 'utf-8'); // reference-style info
|
|
102
|
-
const salt = Buffer.alloc(16, 0); // deterministic salt like reference
|
|
103
|
-
const newKey = encryption_1.default.HKDF('sha256', salt, this.encryptionKey, info, 32);
|
|
104
|
-
this.writeKey = newKey;
|
|
105
|
-
this.readKey = newKey;
|
|
106
|
-
this.encryptCount = 0;
|
|
107
|
-
this.decryptCount = 0;
|
|
93
|
+
encryptAudio(message, aad, nonce) {
|
|
94
|
+
return Buffer.concat([
|
|
95
|
+
Buffer.concat(encryption_1.default.encryptAndSeal(message, aad, struct.pack("Q", nonce), this.writeKey)),
|
|
96
|
+
Buffer.from(struct.pack("Q", nonce)),
|
|
97
|
+
]);
|
|
108
98
|
}
|
|
109
99
|
}
|
|
110
100
|
exports.Credentials = Credentials;
|
package/dist/esm/index.js
CHANGED
|
@@ -42,7 +42,6 @@ const node_events_1 = require("node:events");
|
|
|
42
42
|
// Airtunes implementation (node_airtunes2 port) in src/core.
|
|
43
43
|
const index_1 = __importDefault(require("./core/index"));
|
|
44
44
|
const config_1 = __importStar(require("./utils/config"));
|
|
45
|
-
const ntp_1 = require("./utils/ntp");
|
|
46
45
|
class LoxAirplaySender extends node_events_1.EventEmitter {
|
|
47
46
|
airtunes = null;
|
|
48
47
|
deviceKey = null;
|
|
@@ -74,10 +73,6 @@ class LoxAirplaySender extends node_events_1.EventEmitter {
|
|
|
74
73
|
this.airtunes = new index_1.default({
|
|
75
74
|
packetSize: config_1.default.packet_size,
|
|
76
75
|
startTimeMs: options.startTimeMs,
|
|
77
|
-
startTimeNtp: options.startTimeNtp ? (0, ntp_1.toNtpTimestamp)(options.startTimeNtp) : undefined,
|
|
78
|
-
deviceMagic: options.deviceMagic,
|
|
79
|
-
resendBufferSize: options.resendBufferSize,
|
|
80
|
-
underrunMuteMs: options.underrunMuteMs,
|
|
81
76
|
config: options.config,
|
|
82
77
|
});
|
|
83
78
|
this.airtunes.on('device', (key, status, desc) => {
|
|
@@ -24,7 +24,6 @@ class CircularBuffer extends node_events_1.EventEmitter {
|
|
|
24
24
|
buffers = [];
|
|
25
25
|
currentSize = 0;
|
|
26
26
|
status = WAITING;
|
|
27
|
-
hadUnderrun = false;
|
|
28
27
|
constructor(packetsInBuffer, packetSize) {
|
|
29
28
|
super();
|
|
30
29
|
this.packetPool = new packetPool_1.default(packetSize);
|
|
@@ -64,10 +63,6 @@ class CircularBuffer extends node_events_1.EventEmitter {
|
|
|
64
63
|
this.status !== ENDED &&
|
|
65
64
|
(this.status === FILLING || this.currentSize < this.packetSize)) {
|
|
66
65
|
packet.pcm.fill(0);
|
|
67
|
-
if (!this.hadUnderrun) {
|
|
68
|
-
this.hadUnderrun = true;
|
|
69
|
-
this.emit('underrun');
|
|
70
|
-
}
|
|
71
66
|
if (this.status !== FILLING && this.status !== WAITING) {
|
|
72
67
|
this.status = FILLING;
|
|
73
68
|
this.emit('status', 'buffering');
|
|
@@ -97,9 +92,6 @@ class CircularBuffer extends node_events_1.EventEmitter {
|
|
|
97
92
|
}
|
|
98
93
|
}
|
|
99
94
|
this.currentSize -= this.packetSize;
|
|
100
|
-
if (this.hadUnderrun && this.currentSize >= this.packetSize) {
|
|
101
|
-
this.hadUnderrun = false;
|
|
102
|
-
}
|
|
103
95
|
if (this.status === ENDING && this.currentSize <= 0) {
|
|
104
96
|
this.status = ENDED;
|
|
105
97
|
this.currentSize = 0;
|
package/dist/esm/utils/config.js
CHANGED
|
@@ -17,9 +17,9 @@ exports.config = {
|
|
|
17
17
|
coreaudio_check_period: 2000, // CoreAudio buffer level check period
|
|
18
18
|
coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
|
|
19
19
|
sampling_rate: 44100, // fixed by AirTunes v2
|
|
20
|
-
sync_period:
|
|
20
|
+
sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly
|
|
21
21
|
stream_latency: 200, // audio UDP packets are flushed in bursts periodically
|
|
22
|
-
rtsp_timeout:
|
|
22
|
+
rtsp_timeout: 15000, // RTSP servers are considered gone if no reply is received before the timeout
|
|
23
23
|
rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat.
|
|
24
24
|
rtsp_retry_attempts: 3,
|
|
25
25
|
rtsp_retry_base_ms: 300,
|
|
@@ -27,17 +27,7 @@ exports.config = {
|
|
|
27
27
|
rtsp_retry_jitter_ms: 150,
|
|
28
28
|
control_sync_base_delay_ms: 2,
|
|
29
29
|
control_sync_jitter_ms: 3,
|
|
30
|
-
use_monotonic_clock: true,
|
|
31
|
-
jump_forward_enabled: false,
|
|
32
|
-
jump_forward_threshold_ms: 180,
|
|
33
|
-
jump_forward_lead_ms: 220,
|
|
34
30
|
device_magic: (0, numUtil_1.randomInt)(9),
|
|
35
|
-
resend_buffer_size: 1000,
|
|
36
|
-
send_rtcp_sr: true,
|
|
37
|
-
send_rtcp_rr: true,
|
|
38
|
-
send_rtcp_xr: false,
|
|
39
|
-
underrun_mute_ms: 50,
|
|
40
|
-
debug_dump: false,
|
|
41
31
|
ntp_epoch: 0x83aa7e80,
|
|
42
32
|
iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
|
|
43
33
|
rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
|