@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 CHANGED
@@ -1,6 +1,6 @@
1
- # @lox-audioserver/node-airplay-sender
1
+ # lox-airplay-sender
2
2
 
3
- AirPlay sender for RAOP/AirPlay 1 with AirPlay 2 control/auth support. Best-effort AP2 audio (encryption path not validated against native code). Implements RTSP/UDP pipeline, ALAC encoding, and metadata handling with no native dependencies.
3
+ AirPlay sender (RAOP/AirPlay 1 + AirPlay 2 auth) refactored from node_airtunes2 into a modern, typed TypeScript module. It owns the RTSP/UDP pipeline, ALAC encoding, and metadata handling with no native dependencies.
4
4
 
5
5
  ## Requirements
6
6
  - Node.js 18+
@@ -55,12 +55,6 @@ Creates and starts a sender for one AirPlay device. Returns the instance so you
55
55
  - `inputCodec` (`"pcm"` | `"alac"`) Defaults to `"pcm"`.
56
56
  - `airplay2` (boolean) Enable AirPlay 2 auth/flags; default false.
57
57
  - `startTimeMs` (number) Unix ms to align playback across devices.
58
- - `startTimeNtp` (bigint | number | `{ sec, frac }`) Absolute NTP start time (sec<<32|frac) for tighter sync.
59
- - `deviceMagic` (number) Override per-session SSRC/device ID used in RTP/RTCP.
60
- - `resendBufferSize` (number) Override resend cache size (packets); defaults to 1000.
61
- - `underrunMuteMs` (number) Mute window in ms after an underrun to smooth recovery; defaults to 50.
62
- - `sendRtcpXr` (boolean) Emit RTCP Extended Reports (RRT) alongside sync/SSR; default false.
63
- - `debugDump` (boolean) Log RTSP/RTCP traffic for debugging; default false.
64
58
  - `debug` (boolean) Verbose logging from the transport stack.
65
59
  - `log` `(level, message, data?) => void` Hook for library logs.
66
60
  - `config` (partial) Override buffer/sync/RTSP tuning at runtime (see `src/utils/config.ts` for keys like `packets_in_buffer`, `stream_latency`, `sync_period`, retry/backoff, etc.).
@@ -3,7 +3,6 @@ import type CircularBuffer from '../utils/circularBuffer';
3
3
  type DevicesEmitter = EventEmitter & {
4
4
  on(event: 'airtunes_devices', listener: (hasAirTunes: boolean) => void): DevicesEmitter;
5
5
  on(event: 'need_sync', listener: () => void): DevicesEmitter;
6
- emit(event: 'underrun'): boolean;
7
6
  };
8
7
  /**
9
8
  * Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
@@ -11,33 +10,21 @@ type DevicesEmitter = EventEmitter & {
11
10
  */
12
11
  export default class AudioOut extends EventEmitter {
13
12
  private lastSeq;
14
- private lastWireSeq;
15
13
  private hasAirTunes;
16
14
  private rtpTimeRef;
17
15
  private startTimeMs?;
18
- private startTimeNtp?;
19
16
  private latencyFrames;
20
17
  private latencyApplied;
21
- private seqOffset;
22
- private tsOffset;
23
- private syncOffsetFrames;
24
- private deviceMagic;
25
- private packetCount;
26
- private octetCount;
27
- private readonly frameDurationMs;
28
- private muteUntilMs;
29
18
  /**
30
19
  * Begin pulling from the buffer and emitting packets at the configured cadence.
31
20
  * @param devices Device manager for sync events.
32
21
  * @param circularBuffer PCM/ALAC buffer.
33
22
  * @param startTimeMs Optional unix ms to align playback.
34
- * @param startTimeNtp Optional NTP uint64 (sec<<32|frac) to align playback.
35
23
  */
36
- init(devices: DevicesEmitter, circularBuffer: CircularBuffer, startTimeMs?: number, startTimeNtp?: bigint | number, deviceMagic?: number, underrunMuteMs?: number): void;
24
+ init(devices: DevicesEmitter, circularBuffer: CircularBuffer, startTimeMs?: number): void;
37
25
  /**
38
26
  * Apply latency (in audio frames) when aligning start time.
39
27
  */
40
28
  setLatencyFrames(latencyFrames: number): void;
41
- private handleUnderrun;
42
29
  }
43
30
  export {};
@@ -1,194 +1,69 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
38
5
  Object.defineProperty(exports, "__esModule", { value: true });
39
6
  const node_events_1 = require("node:events");
40
- const node_perf_hooks_1 = require("node:perf_hooks");
41
7
  const config_1 = __importDefault(require("../utils/config"));
42
8
  const numUtil_1 = require("../utils/numUtil");
43
- const ntp_1 = __importStar(require("../utils/ntp"));
44
9
  const SEQ_NUM_WRAP = Math.pow(2, 16);
45
- const FRAC_PER_SEC = 0x1_0000_0000;
46
10
  /**
47
11
  * Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
48
12
  * Emits `packet` events for devices and sync requests (`need_sync`) at intervals.
49
13
  */
50
14
  class AudioOut extends node_events_1.EventEmitter {
51
15
  lastSeq = -1;
52
- lastWireSeq = 0;
53
16
  hasAirTunes = false;
54
- rtpTimeRef = 0;
17
+ rtpTimeRef = Date.now();
55
18
  startTimeMs;
56
- startTimeNtp;
57
19
  latencyFrames = 0;
58
20
  latencyApplied = false;
59
- seqOffset = 0;
60
- tsOffset = 0;
61
- syncOffsetFrames = 0;
62
- deviceMagic = 0;
63
- packetCount = 0;
64
- octetCount = 0;
65
- frameDurationMs = (config_1.default.frames_per_packet / config_1.default.sampling_rate) * 1000;
66
- muteUntilMs = 0;
67
21
  /**
68
22
  * Begin pulling from the buffer and emitting packets at the configured cadence.
69
23
  * @param devices Device manager for sync events.
70
24
  * @param circularBuffer PCM/ALAC buffer.
71
25
  * @param startTimeMs Optional unix ms to align playback.
72
- * @param startTimeNtp Optional NTP uint64 (sec<<32|frac) to align playback.
73
26
  */
74
- init(devices, circularBuffer, startTimeMs, startTimeNtp, deviceMagic, underrunMuteMs) {
27
+ init(devices, circularBuffer, startTimeMs) {
75
28
  this.startTimeMs =
76
29
  typeof startTimeMs === 'number' && Number.isFinite(startTimeMs)
77
30
  ? startTimeMs
78
31
  : undefined;
79
- this.startTimeNtp = startTimeNtp;
80
- this.seqOffset = Math.floor(Math.random() * SEQ_NUM_WRAP);
81
- this.tsOffset = Math.floor(Math.random() * 0xffffffff);
82
- this.syncOffsetFrames = this.tsOffset / config_1.default.frames_per_packet;
83
- this.deviceMagic = typeof deviceMagic === 'number' ? deviceMagic : config_1.default.device_magic;
84
- if (typeof underrunMuteMs === 'number') {
85
- config_1.default.underrun_mute_ms = underrunMuteMs;
86
- }
87
- circularBuffer.on('underrun', () => this.handleUnderrun());
88
- const monoNow = node_perf_hooks_1.performance.now();
89
- const wallToMonoOffset = Date.now() - monoNow;
90
- if (typeof this.startTimeNtp === 'bigint' || typeof this.startTimeNtp === 'number') {
91
- const { sec, frac } = (0, ntp_1.parseNtpTimestamp)(this.startTimeNtp);
92
- const unixMs = (sec - config_1.default.ntp_epoch) * 1000 + Math.floor((frac * 1000) / FRAC_PER_SEC);
93
- const nowMs = config_1.default.use_monotonic_clock ? monoNow : Date.now();
94
- const delta = unixMs - nowMs;
95
- // If target is too close/past, clamp to now.
96
- this.rtpTimeRef = delta > -config_1.default.stream_latency ? unixMs - wallToMonoOffset : nowMs;
97
- }
98
- else if (this.startTimeMs !== undefined) {
99
- const nowMs = config_1.default.use_monotonic_clock ? monoNow : Date.now();
100
- const delta = this.startTimeMs - nowMs;
101
- this.rtpTimeRef = delta > -config_1.default.stream_latency ? this.startTimeMs - wallToMonoOffset : nowMs;
102
- }
103
- else if (config_1.default.use_monotonic_clock) {
104
- this.rtpTimeRef = monoNow;
105
- }
106
- else {
107
- this.rtpTimeRef = Date.now();
108
- }
32
+ this.rtpTimeRef = this.startTimeMs ?? Date.now();
109
33
  devices.on('airtunes_devices', (hasAirTunes) => {
110
34
  this.hasAirTunes = hasAirTunes;
111
35
  });
112
36
  devices.on('need_sync', () => {
113
- this.emit('need_sync', { seq: this.lastWireSeq, tsOffsetFrames: this.syncOffsetFrames });
37
+ this.emit('need_sync', this.lastSeq);
114
38
  });
115
- const syncEvery = config_1.default.sync_period && config_1.default.sync_period > 0
116
- ? config_1.default.sync_period
117
- : Math.max(1, Math.round(config_1.default.sampling_rate / config_1.default.frames_per_packet));
118
39
  const sendPacket = (seq) => {
119
- const wireSeq = (this.seqOffset + seq) % SEQ_NUM_WRAP;
120
40
  const packet = circularBuffer.readPacket();
121
- packet.seq = wireSeq;
122
- packet.timestamp = (0, numUtil_1.low32)(this.tsOffset + wireSeq * config_1.default.frames_per_packet + 2 * config_1.default.sampling_rate);
123
- this.packetCount += 1;
124
- this.octetCount += packet.pcm.length;
125
- if (this.hasAirTunes && seq % syncEvery === 0) {
126
- this.emit('need_sync', {
127
- seq: wireSeq,
128
- tsOffsetFrames: this.syncOffsetFrames,
129
- rtcp: {
130
- rtpTimestamp: packet.timestamp,
131
- ntp: ntp_1.default.timestamp(),
132
- packetCount: this.packetCount,
133
- octetCount: this.octetCount,
134
- xr: { ntp: ntp_1.default.timestamp() },
135
- },
136
- });
137
- const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
41
+ packet.seq = seq % SEQ_NUM_WRAP;
42
+ packet.timestamp = (0, numUtil_1.low32)(seq * config_1.default.frames_per_packet + 2 * config_1.default.sampling_rate);
43
+ if (this.hasAirTunes && seq % config_1.default.sync_period === 0) {
44
+ this.emit('need_sync', seq);
138
45
  const expectedTimeMs = this.rtpTimeRef +
139
- (((seq + this.syncOffsetFrames) * config_1.default.frames_per_packet) / config_1.default.sampling_rate) * 1000;
140
- const deltaMs = nowMs - expectedTimeMs;
141
- this.emit('metrics', { type: 'sync', seq: wireSeq, deltaMs, latencyFrames: this.latencyFrames });
46
+ ((seq * config_1.default.frames_per_packet) / config_1.default.sampling_rate) * 1000;
47
+ const deltaMs = Date.now() - expectedTimeMs;
48
+ this.emit('metrics', { type: 'sync', seq, deltaMs, latencyFrames: this.latencyFrames });
142
49
  }
143
50
  this.emit('packet', packet);
144
- if (this.muteUntilMs > 0) {
145
- const nowMsSend = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
146
- if (nowMsSend < this.muteUntilMs) {
147
- packet.pcm.fill(0);
148
- }
149
- else {
150
- this.muteUntilMs = 0;
151
- }
152
- }
153
51
  packet.release();
154
- this.lastWireSeq = wireSeq;
155
52
  };
156
53
  const syncAudio = () => {
157
- const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
158
- const elapsed = nowMs - this.rtpTimeRef;
54
+ const elapsed = Date.now() - this.rtpTimeRef;
159
55
  if (elapsed < 0) {
160
56
  setTimeout(syncAudio, Math.min(config_1.default.stream_latency, Math.abs(elapsed)));
161
57
  return;
162
58
  }
163
- let currentSeq = Math.floor((elapsed * config_1.default.sampling_rate) / (config_1.default.frames_per_packet * 1000));
164
- // If we're lagging behind significantly, jump forward to avoid long hitches.
165
- if (config_1.default.jump_forward_enabled) {
166
- const expectedTimeMs = this.rtpTimeRef + currentSeq * this.frameDurationMs;
167
- const deltaMs = nowMs - expectedTimeMs;
168
- if (deltaMs > config_1.default.jump_forward_threshold_ms) {
169
- const jumpSeq = Math.ceil((config_1.default.jump_forward_lead_ms * config_1.default.sampling_rate) /
170
- (config_1.default.frames_per_packet * 1000));
171
- const newSeq = currentSeq + jumpSeq;
172
- this.rtpTimeRef = nowMs - newSeq * this.frameDurationMs;
173
- this.lastSeq = newSeq - 1;
174
- currentSeq = newSeq;
175
- }
176
- }
59
+ const currentSeq = Math.floor((elapsed * config_1.default.sampling_rate) / (config_1.default.frames_per_packet * 1000));
177
60
  for (let i = this.lastSeq + 1; i <= currentSeq; i += 1) {
178
61
  sendPacket(i);
179
62
  }
180
63
  this.lastSeq = currentSeq;
181
64
  setTimeout(syncAudio, config_1.default.stream_latency);
182
65
  };
183
- // If a future start is scheduled, defer until then; otherwise start immediately.
184
- const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
185
- const delayMs = Math.max(0, this.rtpTimeRef - nowMs);
186
- if (delayMs > 0) {
187
- setTimeout(syncAudio, delayMs);
188
- }
189
- else {
190
- syncAudio();
191
- }
66
+ syncAudio();
192
67
  }
193
68
  /**
194
69
  * Apply latency (in audio frames) when aligning start time.
@@ -198,23 +73,12 @@ class AudioOut extends node_events_1.EventEmitter {
198
73
  return;
199
74
  }
200
75
  this.latencyFrames = latencyFrames;
201
- if ((this.startTimeMs === undefined && this.startTimeNtp === undefined) || this.latencyApplied) {
76
+ if (this.startTimeMs === undefined || this.latencyApplied) {
202
77
  return;
203
78
  }
204
79
  const latencyMs = (this.latencyFrames / config_1.default.sampling_rate) * 1000;
205
- this.rtpTimeRef -= latencyMs;
80
+ this.rtpTimeRef = this.startTimeMs - latencyMs;
206
81
  this.latencyApplied = true;
207
82
  }
208
- handleUnderrun() {
209
- const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
210
- const currentSeq = Math.max(0, this.lastSeq);
211
- const currentFrames = currentSeq * config_1.default.frames_per_packet;
212
- const offsetMs = (currentFrames / config_1.default.sampling_rate) * 1000;
213
- this.rtpTimeRef = nowMs - offsetMs;
214
- if (config_1.default.underrun_mute_ms > 0) {
215
- this.muteUntilMs = nowMs + config_1.default.underrun_mute_ms;
216
- }
217
- this.emit('underrun');
218
- }
219
83
  }
220
84
  exports.default = AudioOut;
@@ -23,15 +23,14 @@ declare class BufferWithNames {
23
23
  private readonly size;
24
24
  private readonly buffer;
25
25
  constructor(size: number);
26
- add(name: string | number, item: any): void;
27
- getLatestNamed(name: string | number): any;
26
+ add(name: string, item: any): void;
27
+ getLatestNamed(name: string): any;
28
28
  }
29
29
  type AirTunesDeviceInstance = EventEmitter & {
30
30
  audioPacketHistory: BufferWithNames | null;
31
31
  udpServers: UDPServers;
32
32
  audioOut: EventEmitter;
33
33
  type: string;
34
- deviceMagic: number;
35
34
  options: AnyObject;
36
35
  host: string;
37
36
  port: number;
@@ -64,7 +63,6 @@ type AirTunesDeviceInstance = EventEmitter & {
64
63
  doHandshake: () => void;
65
64
  relayAudio: () => void;
66
65
  cleanup: () => void;
67
- onUnderrun?: () => void;
68
66
  };
69
67
  /**
70
68
  * Construct a RAOP/AirPlay device handler.
@@ -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
- const resendBufferSize = Number(options?.resendBufferSize ?? config_1.default.resend_buffer_size ?? 1000);
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
- const transientOption = options?.transient;
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] === '1';
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.relayAudio();
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
- if (this.audioCallback) {
341
- return;
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, this.deviceMagic);
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, tsOffsetFrames = 0, rtcp) {
403
- this.udpServers.sendControlSync(seq, this, tsOffsetFrames, rtcp, { ssrc: this.deviceMagic }, { ntp: rtcp?.xr?.ntp, ssrc: this.deviceMagic });
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', deviceMagic = config_1.default.device_magic) {
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, deviceMagic);
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, deviceMagic) {
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(deviceMagic, 8);
499
+ header.writeUInt32BE(config_1.default.device_magic, 8);
533
500
  return header;
534
501
  }
@@ -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', (payload) => {
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(payload.seq, payload.tsOffsetFrames ?? 0, payload.rtcp);
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) {
@@ -1,7 +1,6 @@
1
1
  import { Duplex } from 'node:stream';
2
2
  import Devices from './devices';
3
3
  import { type AirplayConfig } from '../utils/config';
4
- import { type NtpTimestampInput } from '../utils/ntp';
5
4
  /**
6
5
  * High-level RAOP/AirPlay sender that wires together devices, buffering, and output.
7
6
  * Acts as a Duplex stream: write PCM/ALAC chunks, listen to status events.
@@ -9,20 +8,14 @@ import { type NtpTimestampInput } from '../utils/ntp';
9
8
  declare class AirTunes extends Duplex {
10
9
  readonly devices: Devices;
11
10
  private readonly circularBuffer;
12
- private readonly deviceMagic;
13
11
  /**
14
- * @param options.packetSize Override packet size; defaults to config.
15
- * @param options.startTimeMs Optional unix ms to align playback start.
16
- * @param options.startTimeNtp Optional NTP timestamp (uint64) to align playback start.
17
- */
12
+ * @param options.packetSize Override packet size; defaults to config.
13
+ * @param options.startTimeMs Optional unix ms to align playback start.
14
+ */
18
15
  constructor(options?: {
19
16
  packetSize?: number;
20
17
  startTimeMs?: number;
21
- startTimeNtp?: NtpTimestampInput;
22
18
  config?: Partial<AirplayConfig>;
23
- deviceMagic?: number;
24
- resendBufferSize?: number;
25
- underrunMuteMs?: number;
26
19
  });
27
20
  /** Register an AirTunes (RAOP) device and start streaming to it. */
28
21
  add(host: string, options: Record<string, unknown>, mode?: number, txt?: string[] | string): any;
@@ -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
- * @param options.packetSize Override packet size; defaults to config.
56
- * @param options.startTimeMs Optional unix ms to align playback start.
57
- * @param options.startTimeNtp Optional NTP timestamp (uint64) to align playback start.
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, startTimeNtp, deviceMagic, options.underrunMuteMs);
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, { ...options, deviceMagic: this.deviceMagic, resendBufferSize: options.resendBufferSize }, mode, txt);
84
+ return this.devices.add('airtunes', host, options, mode, txt);
93
85
  }
94
86
  /** Register a CoreAudio output (legacy shim). */
95
87
  addCoreAudio(options) {