@lox-audioserver/node-airplay-sender 0.4.7 → 0.4.9

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;
@@ -309,10 +309,11 @@ AirTunesDevice.prototype.doHandshake = function () {
309
309
  this.emit('status', 'pair_success');
310
310
  });
311
311
  this.rtsp.on('end', (err) => {
312
- this.logLine?.(err);
313
- this.cleanup();
314
- if (err !== 'stopped')
315
- 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);
316
317
  });
317
318
  }
318
319
  catch (e) {
@@ -402,11 +403,11 @@ AirTunesDevice.prototype.onSyncNeeded = function (seq) {
402
403
  this.udpServers.sendControlSync(seq, this);
403
404
  //if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
404
405
  };
405
- AirTunesDevice.prototype.cleanup = function () {
406
+ AirTunesDevice.prototype.cleanup = function (reason = 'stopped') {
406
407
  this.audioSocket = null;
407
408
  this.audioPacketHistory = null;
408
409
  this.status = 'stopped';
409
- this.emit('status', 'stopped');
410
+ this.emit('status', 'stopped', reason);
410
411
  // console.debug('stop');
411
412
  if (this.audioCallback) {
412
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;
@@ -309,10 +309,11 @@ AirTunesDevice.prototype.doHandshake = function () {
309
309
  this.emit('status', 'pair_success');
310
310
  });
311
311
  this.rtsp.on('end', (err) => {
312
- this.logLine?.(err);
313
- this.cleanup();
314
- if (err !== 'stopped')
315
- 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);
316
317
  });
317
318
  }
318
319
  catch (e) {
@@ -402,11 +403,11 @@ AirTunesDevice.prototype.onSyncNeeded = function (seq) {
402
403
  this.udpServers.sendControlSync(seq, this);
403
404
  //if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
404
405
  };
405
- AirTunesDevice.prototype.cleanup = function () {
406
+ AirTunesDevice.prototype.cleanup = function (reason = 'stopped') {
406
407
  this.audioSocket = null;
407
408
  this.audioPacketHistory = null;
408
409
  this.status = 'stopped';
409
- this.emit('status', 'stopped');
410
+ this.emit('status', 'stopped', reason);
410
411
  // console.debug('stop');
411
412
  if (this.audioCallback) {
412
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 });
@@ -113,6 +116,15 @@ class LoxAirplaySender extends node_events_1.EventEmitter {
113
116
  return;
114
117
  this.airtunes.write(chunk);
115
118
  }
119
+ /**
120
+ * Clear the internal circular buffer without tearing down the RTSP session.
121
+ * Use on track switches to drop buffered PCM in place of a full restart.
122
+ */
123
+ reset() {
124
+ if (!this.airtunes)
125
+ return;
126
+ this.airtunes.reset();
127
+ }
116
128
  /**
117
129
  * Pipe a readable stream into the sender; auto-stops on end/error.
118
130
  */
@@ -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
  }
@@ -78,6 +78,11 @@ export declare class LoxAirplaySender extends EventEmitter {
78
78
  * Push raw PCM or ALAC frames into the stream.
79
79
  */
80
80
  sendPcm(chunk: Buffer): void;
81
+ /**
82
+ * Clear the internal circular buffer without tearing down the RTSP session.
83
+ * Use on track switches to drop buffered PCM in place of a full restart.
84
+ */
85
+ reset(): void;
81
86
  /**
82
87
  * Pipe a readable stream into the sender; auto-stops on end/error.
83
88
  */
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 });
@@ -113,6 +116,15 @@ class LoxAirplaySender extends node_events_1.EventEmitter {
113
116
  return;
114
117
  this.airtunes.write(chunk);
115
118
  }
119
+ /**
120
+ * Clear the internal circular buffer without tearing down the RTSP session.
121
+ * Use on track switches to drop buffered PCM in place of a full restart.
122
+ */
123
+ reset() {
124
+ if (!this.airtunes)
125
+ return;
126
+ this.airtunes.reset();
127
+ }
116
128
  /**
117
129
  * Pipe a readable stream into the sender; auto-stops on end/error.
118
130
  */
@@ -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.7",
3
+ "version": "0.4.9",
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",