@lox-audioserver/node-airplay-sender 0.4.6 → 0.4.8
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 +1 -0
- package/dist/core/deviceAirtunes.d.ts +1 -1
- package/dist/core/deviceAirtunes.js +42 -10
- package/dist/core/devices.js +2 -2
- package/dist/esm/core/deviceAirtunes.js +42 -10
- package/dist/esm/core/devices.js +2 -2
- package/dist/esm/index.js +3 -0
- package/dist/esm/utils/http.js +4 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -0
- package/dist/utils/http.js +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Creates and starts a sender for one AirPlay device. Returns the instance so you
|
|
|
61
61
|
|
|
62
62
|
**Events** (sent to `onEvent` callback)
|
|
63
63
|
- `device`: `{ event: "device", message: status, detail: { key, desc } }`
|
|
64
|
+
- `session-ended`: `{ event: "session-ended", message: reason, detail: { key, reason } }`
|
|
64
65
|
- `buffer`: `{ event: "buffer", message: status }` where status is `buffering|playing|drain|end`
|
|
65
66
|
- `error`: `{ event: "error", message }`
|
|
66
67
|
- `metrics`: `{ event: "metrics", detail }` sync drift snapshots emitted on each sync tick when enabled.
|
|
@@ -62,7 +62,7 @@ type AirTunesDeviceInstance = EventEmitter & {
|
|
|
62
62
|
logLine?: (...args: any[]) => void;
|
|
63
63
|
doHandshake: () => void;
|
|
64
64
|
relayAudio: () => void;
|
|
65
|
-
cleanup: () => void;
|
|
65
|
+
cleanup: (reason?: string) => void;
|
|
66
66
|
};
|
|
67
67
|
/**
|
|
68
68
|
* Construct a RAOP/AirPlay device handler.
|
|
@@ -247,7 +247,7 @@ AirTunesDevice.prototype.start = function () {
|
|
|
247
247
|
if (err) {
|
|
248
248
|
this.logLine?.(err.code);
|
|
249
249
|
this.status = 'stopped';
|
|
250
|
-
this.emit('status', 'stopped');
|
|
250
|
+
this.emit('status', 'stopped', 'udp_ports');
|
|
251
251
|
this.logLine?.('port issues');
|
|
252
252
|
this.emit('error', 'udp_ports', err.code);
|
|
253
253
|
return;
|
|
@@ -297,8 +297,7 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
297
297
|
this.rtsp.on('ready', () => {
|
|
298
298
|
this.status = 'playing';
|
|
299
299
|
this.emit('status', 'playing');
|
|
300
|
-
|
|
301
|
-
this.relayAudio();
|
|
300
|
+
this.relayAudio();
|
|
302
301
|
});
|
|
303
302
|
this.rtsp.on('need_password', () => {
|
|
304
303
|
this.emit('status', 'need_password');
|
|
@@ -310,10 +309,11 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
310
309
|
this.emit('status', 'pair_success');
|
|
311
310
|
});
|
|
312
311
|
this.rtsp.on('end', (err) => {
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
|
|
312
|
+
const reason = err == null ? 'unknown' : String(err);
|
|
313
|
+
this.logLine?.(reason);
|
|
314
|
+
this.cleanup(reason);
|
|
315
|
+
if (reason !== 'stopped')
|
|
316
|
+
this.emit(reason);
|
|
317
317
|
});
|
|
318
318
|
}
|
|
319
319
|
catch (e) {
|
|
@@ -328,6 +328,9 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
328
328
|
AirTunesDevice.prototype.relayAudio = function () {
|
|
329
329
|
this.status = 'ready';
|
|
330
330
|
this.emit('status', 'ready');
|
|
331
|
+
let packetCount = 0;
|
|
332
|
+
let byteCount = 0;
|
|
333
|
+
let lastLogAt = 0;
|
|
331
334
|
this.audioCallback = (packet) => {
|
|
332
335
|
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec);
|
|
333
336
|
// if (self.credentials) {
|
|
@@ -335,8 +338,37 @@ AirTunesDevice.prototype.relayAudio = function () {
|
|
|
335
338
|
// }
|
|
336
339
|
if (this.audioSocket == null) {
|
|
337
340
|
this.audioSocket = node_dgram_1.default.createSocket('udp4');
|
|
341
|
+
this.audioSocket.on('error', (err) => {
|
|
342
|
+
this.logLine?.('audio socket error', {
|
|
343
|
+
host: this.host,
|
|
344
|
+
port: this.serverPort,
|
|
345
|
+
message: err instanceof Error ? err.message : String(err),
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
this.audioSocket.send(airTunes, 0, airTunes.length, this.serverPort, this.host, (err) => {
|
|
350
|
+
if (err) {
|
|
351
|
+
this.logLine?.('audio packet send failed', {
|
|
352
|
+
host: this.host,
|
|
353
|
+
port: this.serverPort,
|
|
354
|
+
message: err instanceof Error ? err.message : String(err),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
packetCount += 1;
|
|
359
|
+
byteCount += airTunes.length;
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
if (now - lastLogAt > 5000) {
|
|
362
|
+
lastLogAt = now;
|
|
363
|
+
if (this.options?.debug) {
|
|
364
|
+
this.logLine?.('audio packet send stats', {
|
|
365
|
+
host: this.host,
|
|
366
|
+
port: this.serverPort,
|
|
367
|
+
packets: packetCount,
|
|
368
|
+
bytes: byteCount,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
338
371
|
}
|
|
339
|
-
this.audioSocket.send(airTunes, 0, airTunes.length, this.serverPort, this.host);
|
|
340
372
|
};
|
|
341
373
|
// this.sendAirTunesPacket = function(airTunes) {
|
|
342
374
|
// try{
|
|
@@ -371,11 +403,11 @@ AirTunesDevice.prototype.onSyncNeeded = function (seq) {
|
|
|
371
403
|
this.udpServers.sendControlSync(seq, this);
|
|
372
404
|
//if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
|
|
373
405
|
};
|
|
374
|
-
AirTunesDevice.prototype.cleanup = function () {
|
|
406
|
+
AirTunesDevice.prototype.cleanup = function (reason = 'stopped') {
|
|
375
407
|
this.audioSocket = null;
|
|
376
408
|
this.audioPacketHistory = null;
|
|
377
409
|
this.status = 'stopped';
|
|
378
|
-
this.emit('status', 'stopped');
|
|
410
|
+
this.emit('status', 'stopped', reason);
|
|
379
411
|
// console.debug('stop');
|
|
380
412
|
if (this.audioCallback) {
|
|
381
413
|
this.audioOut.removeListener('packet', this.audioCallback);
|
package/dist/core/devices.js
CHANGED
|
@@ -56,7 +56,7 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
56
56
|
return previousDev;
|
|
57
57
|
}
|
|
58
58
|
this.devices[dev.key] = dev;
|
|
59
|
-
dev.on('status', (status) => {
|
|
59
|
+
dev.on('status', (status, desc = '') => {
|
|
60
60
|
if (status === 'error' || status === 'stopped') {
|
|
61
61
|
delete this.devices[dev.key];
|
|
62
62
|
this.checkAirTunesDevices();
|
|
@@ -64,7 +64,7 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
64
64
|
if (this.hasAirTunes && status === 'playing') {
|
|
65
65
|
this.emit('need_sync');
|
|
66
66
|
}
|
|
67
|
-
this.emit('status', dev.key, status,
|
|
67
|
+
this.emit('status', dev.key, status, desc);
|
|
68
68
|
});
|
|
69
69
|
dev.start();
|
|
70
70
|
this.checkAirTunesDevices();
|
|
@@ -247,7 +247,7 @@ AirTunesDevice.prototype.start = function () {
|
|
|
247
247
|
if (err) {
|
|
248
248
|
this.logLine?.(err.code);
|
|
249
249
|
this.status = 'stopped';
|
|
250
|
-
this.emit('status', 'stopped');
|
|
250
|
+
this.emit('status', 'stopped', 'udp_ports');
|
|
251
251
|
this.logLine?.('port issues');
|
|
252
252
|
this.emit('error', 'udp_ports', err.code);
|
|
253
253
|
return;
|
|
@@ -297,8 +297,7 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
297
297
|
this.rtsp.on('ready', () => {
|
|
298
298
|
this.status = 'playing';
|
|
299
299
|
this.emit('status', 'playing');
|
|
300
|
-
|
|
301
|
-
this.relayAudio();
|
|
300
|
+
this.relayAudio();
|
|
302
301
|
});
|
|
303
302
|
this.rtsp.on('need_password', () => {
|
|
304
303
|
this.emit('status', 'need_password');
|
|
@@ -310,10 +309,11 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
310
309
|
this.emit('status', 'pair_success');
|
|
311
310
|
});
|
|
312
311
|
this.rtsp.on('end', (err) => {
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
|
|
312
|
+
const reason = err == null ? 'unknown' : String(err);
|
|
313
|
+
this.logLine?.(reason);
|
|
314
|
+
this.cleanup(reason);
|
|
315
|
+
if (reason !== 'stopped')
|
|
316
|
+
this.emit(reason);
|
|
317
317
|
});
|
|
318
318
|
}
|
|
319
319
|
catch (e) {
|
|
@@ -328,6 +328,9 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
328
328
|
AirTunesDevice.prototype.relayAudio = function () {
|
|
329
329
|
this.status = 'ready';
|
|
330
330
|
this.emit('status', 'ready');
|
|
331
|
+
let packetCount = 0;
|
|
332
|
+
let byteCount = 0;
|
|
333
|
+
let lastLogAt = 0;
|
|
331
334
|
this.audioCallback = (packet) => {
|
|
332
335
|
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec);
|
|
333
336
|
// if (self.credentials) {
|
|
@@ -335,8 +338,37 @@ AirTunesDevice.prototype.relayAudio = function () {
|
|
|
335
338
|
// }
|
|
336
339
|
if (this.audioSocket == null) {
|
|
337
340
|
this.audioSocket = node_dgram_1.default.createSocket('udp4');
|
|
341
|
+
this.audioSocket.on('error', (err) => {
|
|
342
|
+
this.logLine?.('audio socket error', {
|
|
343
|
+
host: this.host,
|
|
344
|
+
port: this.serverPort,
|
|
345
|
+
message: err instanceof Error ? err.message : String(err),
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
this.audioSocket.send(airTunes, 0, airTunes.length, this.serverPort, this.host, (err) => {
|
|
350
|
+
if (err) {
|
|
351
|
+
this.logLine?.('audio packet send failed', {
|
|
352
|
+
host: this.host,
|
|
353
|
+
port: this.serverPort,
|
|
354
|
+
message: err instanceof Error ? err.message : String(err),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
packetCount += 1;
|
|
359
|
+
byteCount += airTunes.length;
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
if (now - lastLogAt > 5000) {
|
|
362
|
+
lastLogAt = now;
|
|
363
|
+
if (this.options?.debug) {
|
|
364
|
+
this.logLine?.('audio packet send stats', {
|
|
365
|
+
host: this.host,
|
|
366
|
+
port: this.serverPort,
|
|
367
|
+
packets: packetCount,
|
|
368
|
+
bytes: byteCount,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
338
371
|
}
|
|
339
|
-
this.audioSocket.send(airTunes, 0, airTunes.length, this.serverPort, this.host);
|
|
340
372
|
};
|
|
341
373
|
// this.sendAirTunesPacket = function(airTunes) {
|
|
342
374
|
// try{
|
|
@@ -371,11 +403,11 @@ AirTunesDevice.prototype.onSyncNeeded = function (seq) {
|
|
|
371
403
|
this.udpServers.sendControlSync(seq, this);
|
|
372
404
|
//if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
|
|
373
405
|
};
|
|
374
|
-
AirTunesDevice.prototype.cleanup = function () {
|
|
406
|
+
AirTunesDevice.prototype.cleanup = function (reason = 'stopped') {
|
|
375
407
|
this.audioSocket = null;
|
|
376
408
|
this.audioPacketHistory = null;
|
|
377
409
|
this.status = 'stopped';
|
|
378
|
-
this.emit('status', 'stopped');
|
|
410
|
+
this.emit('status', 'stopped', reason);
|
|
379
411
|
// console.debug('stop');
|
|
380
412
|
if (this.audioCallback) {
|
|
381
413
|
this.audioOut.removeListener('packet', this.audioCallback);
|
package/dist/esm/core/devices.js
CHANGED
|
@@ -56,7 +56,7 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
56
56
|
return previousDev;
|
|
57
57
|
}
|
|
58
58
|
this.devices[dev.key] = dev;
|
|
59
|
-
dev.on('status', (status) => {
|
|
59
|
+
dev.on('status', (status, desc = '') => {
|
|
60
60
|
if (status === 'error' || status === 'stopped') {
|
|
61
61
|
delete this.devices[dev.key];
|
|
62
62
|
this.checkAirTunesDevices();
|
|
@@ -64,7 +64,7 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
64
64
|
if (this.hasAirTunes && status === 'playing') {
|
|
65
65
|
this.emit('need_sync');
|
|
66
66
|
}
|
|
67
|
-
this.emit('status', dev.key, status,
|
|
67
|
+
this.emit('status', dev.key, status, desc);
|
|
68
68
|
});
|
|
69
69
|
dev.start();
|
|
70
70
|
this.checkAirTunesDevices();
|
package/dist/esm/index.js
CHANGED
|
@@ -77,6 +77,9 @@ class LoxAirplaySender extends node_events_1.EventEmitter {
|
|
|
77
77
|
});
|
|
78
78
|
this.airtunes.on('device', (key, status, desc) => {
|
|
79
79
|
onEvent?.({ event: 'device', message: status, detail: { key, desc } });
|
|
80
|
+
if (status === 'stopped') {
|
|
81
|
+
onEvent?.({ event: 'session-ended', message: desc || 'stopped', detail: { key, reason: desc || 'stopped' } });
|
|
82
|
+
}
|
|
80
83
|
});
|
|
81
84
|
this.airtunes.on('buffer', (status) => {
|
|
82
85
|
onEvent?.({ event: 'buffer', message: status });
|
package/dist/esm/utils/http.js
CHANGED
|
@@ -98,15 +98,16 @@ class HttpClient {
|
|
|
98
98
|
port
|
|
99
99
|
}, resolve);
|
|
100
100
|
this.socket.on('data', data => {
|
|
101
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
101
102
|
if (!this.pendingResponse) {
|
|
102
103
|
// there is no response pending, parse the data.
|
|
103
|
-
this.parseResponse(
|
|
104
|
+
this.parseResponse(chunk);
|
|
104
105
|
}
|
|
105
106
|
else {
|
|
106
107
|
// incoming data for the pending response.
|
|
107
108
|
const existing = this.pendingResponse.res.body ?? Buffer.alloc(0);
|
|
108
|
-
this.pendingResponse.res.body = Buffer.concat([existing,
|
|
109
|
-
this.pendingResponse.remaining -=
|
|
109
|
+
this.pendingResponse.res.body = Buffer.concat([existing, chunk], chunk.byteLength + existing.byteLength);
|
|
110
|
+
this.pendingResponse.remaining -= chunk.byteLength;
|
|
110
111
|
if (this.pendingResponse.remaining === 0) {
|
|
111
112
|
// all remaining data for the pending response has been read; resolve the promise for the
|
|
112
113
|
// corresponding request.
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -77,6 +77,9 @@ class LoxAirplaySender extends node_events_1.EventEmitter {
|
|
|
77
77
|
});
|
|
78
78
|
this.airtunes.on('device', (key, status, desc) => {
|
|
79
79
|
onEvent?.({ event: 'device', message: status, detail: { key, desc } });
|
|
80
|
+
if (status === 'stopped') {
|
|
81
|
+
onEvent?.({ event: 'session-ended', message: desc || 'stopped', detail: { key, reason: desc || 'stopped' } });
|
|
82
|
+
}
|
|
80
83
|
});
|
|
81
84
|
this.airtunes.on('buffer', (status) => {
|
|
82
85
|
onEvent?.({ event: 'buffer', message: status });
|
package/dist/utils/http.js
CHANGED
|
@@ -98,15 +98,16 @@ class HttpClient {
|
|
|
98
98
|
port
|
|
99
99
|
}, resolve);
|
|
100
100
|
this.socket.on('data', data => {
|
|
101
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
101
102
|
if (!this.pendingResponse) {
|
|
102
103
|
// there is no response pending, parse the data.
|
|
103
|
-
this.parseResponse(
|
|
104
|
+
this.parseResponse(chunk);
|
|
104
105
|
}
|
|
105
106
|
else {
|
|
106
107
|
// incoming data for the pending response.
|
|
107
108
|
const existing = this.pendingResponse.res.body ?? Buffer.alloc(0);
|
|
108
|
-
this.pendingResponse.res.body = Buffer.concat([existing,
|
|
109
|
-
this.pendingResponse.remaining -=
|
|
109
|
+
this.pendingResponse.res.body = Buffer.concat([existing, chunk], chunk.byteLength + existing.byteLength);
|
|
110
|
+
this.pendingResponse.remaining -= chunk.byteLength;
|
|
110
111
|
if (this.pendingResponse.remaining === 0) {
|
|
111
112
|
// all remaining data for the pending response has been read; resolve the promise for the
|
|
112
113
|
// corresponding request.
|
package/package.json
CHANGED