@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 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
- if (this.airplay2)
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
- this.logLine?.(err);
314
- this.cleanup();
315
- if (err !== 'stopped')
316
- this.emit(err);
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);
@@ -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
- if (this.airplay2)
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
- this.logLine?.(err);
314
- this.cleanup();
315
- if (err !== 'stopped')
316
- this.emit(err);
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);
@@ -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 });
@@ -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(data);
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, data], data.byteLength + existing.byteLength);
109
- this.pendingResponse.remaining -= data.byteLength;
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
@@ -51,7 +51,7 @@ export interface AirplayMetadata {
51
51
  durationMs?: number;
52
52
  }
53
53
  export interface LoxAirplayEvent {
54
- event: string;
54
+ event: 'device' | 'buffer' | 'error' | 'metrics' | 'session-ended' | string;
55
55
  message?: string;
56
56
  detail?: any;
57
57
  }
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 });
@@ -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(data);
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, data], data.byteLength + existing.byteLength);
109
- this.pendingResponse.remaining -= data.byteLength;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-airplay-sender",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "AirPlay sender (RAOP/AirPlay 1; AirPlay 2 control/auth, best-effort audio)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",