@basmilius/apple-raop 0.9.18 → 0.10.0

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/dist/index.mjs CHANGED
@@ -6,32 +6,74 @@ import { Context, Discovery, generateActiveRemoteId, generateDacpId, generateSes
6
6
  import { RtspClient } from "@basmilius/apple-rtsp";
7
7
 
8
8
  //#region src/types.ts
9
+ /**
10
+ * Bitmask enum for encryption types supported by the RAOP receiver.
11
+ * Values are parsed from the `et` mDNS TXT record field.
12
+ */
9
13
  let EncryptionType = /* @__PURE__ */ function(EncryptionType) {
14
+ /** No encryption information available. */
10
15
  EncryptionType[EncryptionType["Unknown"] = 0] = "Unknown";
16
+ /** Receiver supports unencrypted streams. */
11
17
  EncryptionType[EncryptionType["Unencrypted"] = 1] = "Unencrypted";
18
+ /** Receiver supports MFi-SAP encryption (AirPort Express). */
12
19
  EncryptionType[EncryptionType["MFiSAP"] = 2] = "MFiSAP";
13
20
  return EncryptionType;
14
21
  }({});
22
+ /**
23
+ * Bitmask enum for metadata types supported by the RAOP receiver.
24
+ * Values are parsed from the `md` mDNS TXT record field.
25
+ */
15
26
  let MetadataType = /* @__PURE__ */ function(MetadataType) {
27
+ /** Receiver does not support metadata. */
16
28
  MetadataType[MetadataType["NotSupported"] = 0] = "NotSupported";
29
+ /** Receiver supports text metadata (title, artist, album). */
17
30
  MetadataType[MetadataType["Text"] = 1] = "Text";
31
+ /** Receiver supports album artwork. */
18
32
  MetadataType[MetadataType["Artwork"] = 2] = "Artwork";
33
+ /** Receiver supports progress/duration information. */
19
34
  MetadataType[MetadataType["Progress"] = 4] = "Progress";
20
35
  return MetadataType;
21
36
  }({});
22
37
 
23
38
  //#endregion
24
39
  //#region src/packets.ts
40
+ /**
41
+ * Fixed-size FIFO buffer for recently sent RTP audio packets.
42
+ * Used to fulfill retransmission requests from the receiver when
43
+ * packets are lost in transit.
44
+ */
25
45
  var PacketFifo = class {
46
+ /** Maximum number of packets to retain. */
26
47
  #maxSize;
48
+ /** Map of sequence number to packet data. */
27
49
  #packets = /* @__PURE__ */ new Map();
50
+ /** Insertion-ordered list of sequence numbers for eviction. */
28
51
  #order = [];
52
+ /**
53
+ * Creates a new packet FIFO with the given capacity.
54
+ *
55
+ * @param maxSize - Maximum number of packets to store before evicting the oldest.
56
+ */
29
57
  constructor(maxSize) {
30
58
  this.#maxSize = maxSize;
31
59
  }
60
+ /**
61
+ * Retrieves a packet by its RTP sequence number.
62
+ *
63
+ * @param seqno - RTP sequence number to look up.
64
+ * @returns The packet buffer, or undefined if not in the backlog.
65
+ */
32
66
  get(seqno) {
33
67
  return this.#packets.get(seqno);
34
68
  }
69
+ /**
70
+ * Stores a packet in the backlog. If the sequence number already
71
+ * exists, the call is ignored. When the backlog exceeds its maximum
72
+ * size, the oldest packets are evicted.
73
+ *
74
+ * @param seqno - RTP sequence number of the packet.
75
+ * @param packet - Full RTP packet data including header.
76
+ */
35
77
  set(seqno, packet) {
36
78
  if (this.#packets.has(seqno)) return;
37
79
  this.#packets.set(seqno, packet);
@@ -41,14 +83,27 @@ var PacketFifo = class {
41
83
  if (oldest !== void 0) this.#packets.delete(oldest);
42
84
  }
43
85
  }
86
+ /**
87
+ * Checks whether a packet with the given sequence number is in the backlog.
88
+ *
89
+ * @param seqno - RTP sequence number to check.
90
+ * @returns True if the packet exists in the backlog.
91
+ */
44
92
  has(seqno) {
45
93
  return this.#packets.has(seqno);
46
94
  }
95
+ /**
96
+ * Removes all packets from the backlog.
97
+ */
47
98
  clear() {
48
99
  this.#packets.clear();
49
100
  this.#order.length = 0;
50
101
  }
51
102
  };
103
+ /**
104
+ * Encoder for RTP audio packet headers (12 bytes).
105
+ * Produces the standard RTP fixed header used for RAOP audio data packets.
106
+ */
52
107
  const AudioPacketHeader = { encode(header, payloadType, seqno, timestamp, ssrc) {
53
108
  const packet = Buffer.allocUnsafe(12);
54
109
  packet.writeUInt8(header, 0);
@@ -58,6 +113,11 @@ const AudioPacketHeader = { encode(header, payloadType, seqno, timestamp, ssrc)
58
113
  packet.writeUInt32BE(ssrc, 8);
59
114
  return packet;
60
115
  } };
116
+ /**
117
+ * Encoder for RAOP timing synchronization packets (20 bytes).
118
+ * Sent periodically over the control channel to synchronize
119
+ * the receiver's playback clock with the sender's RTP timestamps.
120
+ */
61
121
  const SyncPacket = { encode(header, payloadType, seqno, rtpTimestamp, ntpSec, ntpFrac, rtpTimestampNow) {
62
122
  const packet = Buffer.allocUnsafe(20);
63
123
  packet.writeUInt8(header, 0);
@@ -69,6 +129,13 @@ const SyncPacket = { encode(header, payloadType, seqno, rtpTimestamp, ntpSec, nt
69
129
  packet.writeUInt32BE(rtpTimestampNow, 16);
70
130
  return packet;
71
131
  } };
132
+ /**
133
+ * Decodes a retransmit request packet received on the control channel.
134
+ * The request contains the starting sequence number and count of lost packets.
135
+ *
136
+ * @param data - Raw UDP packet data from the receiver.
137
+ * @returns Parsed retransmit request with sequence number and packet count.
138
+ */
72
139
  function decodeRetransmitRequest(data) {
73
140
  return {
74
141
  lostSeqno: data.readUInt16BE(4),
@@ -78,11 +145,25 @@ function decodeRetransmitRequest(data) {
78
145
 
79
146
  //#endregion
80
147
  //#region src/utils.ts
148
+ /**
149
+ * Converts a linear volume percentage (0-100) to a dBFS value
150
+ * suitable for the RAOP `volume` SET_PARAMETER command.
151
+ *
152
+ * @param volume - Volume as a percentage (0 = silent, 100 = full).
153
+ * @returns Volume in dBFS (-144 for mute, 0 for full volume).
154
+ */
81
155
  function pctToDbfs(volume) {
82
156
  if (volume <= 0) return -144;
83
157
  if (volume >= 100) return 0;
84
158
  return 20 * Math.log10(volume / 100);
85
159
  }
160
+ /**
161
+ * Parses the `et` (encryption types) field from mDNS TXT record
162
+ * properties into an EncryptionType bitmask.
163
+ *
164
+ * @param properties - mDNS TXT record key-value pairs.
165
+ * @returns Bitmask of supported encryption types.
166
+ */
86
167
  function getEncryptionTypes(properties) {
87
168
  const et = properties.get("et");
88
169
  if (!et) return EncryptionType.Unknown;
@@ -94,6 +175,13 @@ function getEncryptionTypes(properties) {
94
175
  }
95
176
  return types;
96
177
  }
178
+ /**
179
+ * Parses the `md` (metadata types) field from mDNS TXT record
180
+ * properties into a MetadataType bitmask.
181
+ *
182
+ * @param properties - mDNS TXT record key-value pairs.
183
+ * @returns Bitmask of supported metadata types.
184
+ */
97
185
  function getMetadataTypes(properties) {
98
186
  const md = properties.get("md");
99
187
  if (!md) return MetadataType.NotSupported;
@@ -106,6 +194,14 @@ function getMetadataTypes(properties) {
106
194
  }
107
195
  return types;
108
196
  }
197
+ /**
198
+ * Extracts audio format properties from mDNS TXT record fields.
199
+ * Falls back to CD-quality defaults (44100 Hz, 2 channels, 16-bit)
200
+ * when properties are missing.
201
+ *
202
+ * @param properties - mDNS TXT record key-value pairs.
203
+ * @returns A tuple of [sampleRate, channels, bytesPerChannel].
204
+ */
109
205
  function getAudioProperties(properties) {
110
206
  return [
111
207
  parseInt(properties.get("sr") ?? "44100", 10),
@@ -116,31 +212,81 @@ function getAudioProperties(properties) {
116
212
 
117
213
  //#endregion
118
214
  //#region src/controlClient.ts
119
- function ntpFromTs(timestamp, sampleRate) {
120
- const seconds = Math.floor(timestamp / sampleRate);
121
- const fraction = timestamp % sampleRate * 4294967295 / sampleRate;
122
- return BigInt(seconds) << 32n | BigInt(Math.floor(fraction));
215
+ /**
216
+ * Converts an RTP timestamp to a wall-clock NTP timestamp using anchor points.
217
+ *
218
+ * Uses a fixed anchor pair (RTP timestamp + NTP time) established when the
219
+ * stream starts. The elapsed samples from the anchor are computed with 32-bit
220
+ * unsigned wrap handling, then converted to an NTP offset added to the anchor NTP.
221
+ *
222
+ * @param rtpTimestamp - Current RTP timestamp in audio frames.
223
+ * @param sampleRate - Audio sample rate in Hz.
224
+ * @param anchorRtp - RTP timestamp at the anchor point.
225
+ * @param anchorNtp - NTP timestamp at the anchor point.
226
+ * @returns 64-bit NTP wall-clock timestamp.
227
+ */
228
+ function ntpFromTs(rtpTimestamp, sampleRate, anchorRtp, anchorNtp) {
229
+ let elapsedSamples;
230
+ if (rtpTimestamp >= anchorRtp) elapsedSamples = rtpTimestamp - anchorRtp;
231
+ else elapsedSamples = 4294967296 - anchorRtp + rtpTimestamp;
232
+ const elapsedSeconds = Math.floor(elapsedSamples / sampleRate);
233
+ const elapsedFraction = elapsedSamples % sampleRate * 4294967295 / sampleRate;
234
+ return anchorNtp + (BigInt(elapsedSeconds) << 32n | BigInt(Math.floor(elapsedFraction)));
123
235
  }
236
+ /**
237
+ * UDP control channel client for RAOP streaming. Responsible for two tasks:
238
+ * 1. Sending periodic timing sync packets to the receiver so it can
239
+ * synchronize its playback clock with our RTP timestamps.
240
+ * 2. Handling retransmit requests from the receiver by resending lost
241
+ * packets from the packet backlog.
242
+ */
124
243
  var ControlClient = class extends EventEmitter {
244
+ /** Application context providing logger and device identity. */
245
+ #appContext;
246
+ /** UDP socket for the control channel. */
125
247
  #transport;
248
+ /** Shared streaming state containing timestamps, ports, and audio format. */
126
249
  #context;
250
+ /** FIFO backlog of recently sent packets for retransmission. */
127
251
  #packetBacklog;
252
+ /** Interval timer handle for periodic sync packet sending. */
128
253
  #syncTask;
129
- #abortController;
254
+ /** Local UDP port assigned after binding. */
130
255
  #localPort;
131
- constructor(context, packetBacklog) {
256
+ /** RTP timestamp at the anchor point for NTP conversion. */
257
+ #anchorRtp = 0;
258
+ /** NTP wall-clock timestamp at the anchor point. */
259
+ #anchorNtp = 0n;
260
+ /**
261
+ * Creates a new control client.
262
+ *
263
+ * @param appContext - Application context for logging.
264
+ * @param context - Shared stream context with RTP state.
265
+ * @param packetBacklog - Packet FIFO for retransmit lookups.
266
+ */
267
+ constructor(appContext, context, packetBacklog) {
132
268
  super();
269
+ this.#appContext = appContext;
133
270
  this.#context = context;
134
271
  this.#packetBacklog = packetBacklog;
135
272
  }
273
+ /** Local UDP port the control channel is bound to, or 0 if not yet bound. */
136
274
  get port() {
137
275
  return this.#localPort ?? 0;
138
276
  }
277
+ /**
278
+ * Binds the control channel UDP socket to a local address and port.
279
+ * Resolves once the socket is listening and the assigned port is known.
280
+ *
281
+ * @param localIp - Local IP address to bind to.
282
+ * @param port - Desired port number (0 for auto-assign).
283
+ * @throws When the UDP socket encounters a bind error.
284
+ */
139
285
  async bind(localIp, port) {
140
286
  return new Promise((resolve, reject) => {
141
287
  this.#transport = createSocket("udp4");
142
288
  this.#transport.on("error", (err) => {
143
- console.error("Control connection error:", err);
289
+ this.#appContext.logger.error("[raop-control]", "Control connection error:", err);
144
290
  reject(err);
145
291
  });
146
292
  this.#transport.on("message", (data, rinfo) => {
@@ -153,6 +299,9 @@ var ControlClient = class extends EventEmitter {
153
299
  this.#transport.bind(port, localIp);
154
300
  });
155
301
  }
302
+ /**
303
+ * Stops the sync task and closes the UDP socket, releasing all resources.
304
+ */
156
305
  close() {
157
306
  this.stop();
158
307
  if (this.#transport) {
@@ -160,26 +309,41 @@ var ControlClient = class extends EventEmitter {
160
309
  this.#transport = void 0;
161
310
  }
162
311
  }
312
+ /**
313
+ * Starts sending periodic sync packets to the receiver. Sync packets
314
+ * are sent immediately and then every second to keep the receiver's
315
+ * playback clock aligned.
316
+ *
317
+ * @param remoteAddr - IP address of the RAOP receiver.
318
+ * @throws When the sync task is already running.
319
+ */
163
320
  start(remoteAddr) {
164
321
  if (this.#syncTask) throw new Error("Already running");
165
- this.#abortController = new AbortController();
322
+ this.#anchorRtp = this.#context.headTs;
323
+ this.#anchorNtp = NTP.now();
166
324
  this.#startSyncTask(remoteAddr, this.#context.controlPort);
167
325
  }
326
+ /**
327
+ * Stops the periodic sync task without closing the UDP socket.
328
+ */
168
329
  stop() {
169
- if (this.#abortController) {
170
- this.#abortController.abort();
171
- this.#abortController = void 0;
172
- }
173
330
  if (this.#syncTask) {
174
331
  clearInterval(this.#syncTask);
175
332
  this.#syncTask = void 0;
176
333
  }
177
334
  }
335
+ /**
336
+ * Starts the periodic sync packet sending loop. The first packet uses
337
+ * header byte 0x90 (marker bit set), subsequent packets use 0x80.
338
+ *
339
+ * @param addr - Remote receiver IP address.
340
+ * @param port - Remote control port.
341
+ */
178
342
  #startSyncTask(addr, port) {
179
343
  let firstPacket = true;
180
344
  const sendSync = () => {
181
345
  if (!this.#transport) return;
182
- const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate);
346
+ const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate, this.#anchorRtp, this.#anchorNtp);
183
347
  const [currentSec, currentFrac] = NTP.parts(currentTime);
184
348
  const packet = SyncPacket.encode(firstPacket ? 144 : 128, 212, 7, this.#context.headTs - this.#context.latency, currentSec, currentFrac, this.#context.headTs);
185
349
  firstPacket = false;
@@ -188,15 +352,32 @@ var ControlClient = class extends EventEmitter {
188
352
  sendSync();
189
353
  this.#syncTask = setInterval(sendSync, 1e3);
190
354
  }
355
+ /**
356
+ * Handles incoming UDP messages on the control channel. Dispatches
357
+ * retransmit requests (type 0x55) and logs unhandled message types.
358
+ *
359
+ * @param data - Raw UDP packet data.
360
+ * @param rinfo - Remote address info of the sender.
361
+ */
191
362
  #onMessage(data, rinfo) {
192
363
  if ((data[1] & 127) === 85) this.#retransmitLostPackets(decodeRetransmitRequest(data), rinfo);
193
- else console.debug("Received unhandled control data from", rinfo, data);
194
- }
364
+ else this.#appContext.logger.debug("[raop-control]", "Received unhandled control data from", rinfo, data);
365
+ }
366
+ /**
367
+ * Resends lost packets requested by the receiver. For each requested
368
+ * sequence number, sends the original packet wrapped in a retransmit
369
+ * response (type 0xD6). If the packet is no longer in the backlog,
370
+ * sends an empty futile response to acknowledge the request.
371
+ *
372
+ * @param request - Parsed retransmit request with starting sequence number and count.
373
+ * @param addr - Remote address to send retransmitted packets to.
374
+ */
195
375
  #retransmitLostPackets(request, addr) {
196
376
  for (let i = 0; i < request.lostPackets; i++) {
197
- const seqno = request.lostSeqno + i;
377
+ const seqno = request.lostSeqno + i & 65535;
198
378
  if (this.#packetBacklog.has(seqno)) {
199
379
  const packet = this.#packetBacklog.get(seqno);
380
+ if (packet.byteLength < 4) continue;
200
381
  const originalSeqno = packet.subarray(2, 4);
201
382
  const resp = Buffer.concat([
202
383
  Buffer.from([128, 214]),
@@ -204,16 +385,32 @@ var ControlClient = class extends EventEmitter {
204
385
  packet
205
386
  ]);
206
387
  if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
207
- } else console.debug(`Packet ${seqno} not in backlog`);
388
+ } else {
389
+ const seqBuf = Buffer.alloc(2);
390
+ seqBuf.writeUInt16BE(seqno);
391
+ const resp = Buffer.concat([
392
+ Buffer.from([128, 214]),
393
+ seqBuf,
394
+ Buffer.alloc(4)
395
+ ]);
396
+ if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
397
+ }
208
398
  }
209
399
  }
210
400
  };
211
401
 
212
402
  //#endregion
213
403
  //#region src/rtspClient.ts
404
+ /** User-Agent header value sent with all RTSP requests. */
214
405
  const USER_AGENT = "AirPlay/550.10";
406
+ /** Number of audio frames per RTP packet, used in SDP ANNOUNCE payload. */
215
407
  const FRAMES_PER_PACKET$1 = 352;
408
+ /** Single-byte flag indicating unencrypted auth-setup mode. */
216
409
  const AUTH_SETUP_UNENCRYPTED = Buffer.from([1]);
410
+ /**
411
+ * Static Curve25519 public key sent during `/auth-setup` for
412
+ * MFi-SAP authentication with AirPort Express devices.
413
+ */
217
414
  const CURVE25519_PUB_KEY = Buffer.from([
218
415
  89,
219
416
  2,
@@ -248,12 +445,27 @@ const CURVE25519_PUB_KEY = Buffer.from([
248
445
  29,
249
446
  78
250
447
  ]);
448
+ /**
449
+ * Computes an HTTP Digest authentication header value using MD5.
450
+ *
451
+ * @param method - RTSP method (may be empty for default headers).
452
+ * @param uri - Request URI.
453
+ * @param info - Digest credentials and challenge parameters.
454
+ * @returns Formatted Digest authorization header value.
455
+ */
251
456
  function getDigestPayload(method, uri, info) {
252
457
  const ha1 = createHash("md5").update(`${info.username}:${info.realm}:${info.password}`).digest("hex");
253
458
  const ha2 = createHash("md5").update(`${method}:${uri}`).digest("hex");
254
459
  const response = createHash("md5").update(`${ha1}:${info.nonce}:${ha2}`).digest("hex");
255
460
  return `Digest username="${info.username}", realm="${info.realm}", nonce="${info.nonce}", uri="${uri}", response="${response}"`;
256
461
  }
462
+ /**
463
+ * Builds an SDP (Session Description Protocol) body for the RTSP
464
+ * ANNOUNCE request, describing the audio format and codec parameters.
465
+ *
466
+ * @param options - Audio format and connection details.
467
+ * @returns SDP payload string with CRLF line endings.
468
+ */
257
469
  function buildAnnouncePayload(options) {
258
470
  return [
259
471
  "v=0",
@@ -266,44 +478,70 @@ function buildAnnouncePayload(options) {
266
478
  `a=fmtp:96 ${FRAMES_PER_PACKET$1} 0 ${options.bitsPerChannel} 40 10 14 ${options.channels} 255 0 0 ${options.sampleRate}`
267
479
  ].join("\r\n") + "\r\n";
268
480
  }
481
+ /**
482
+ * RAOP-specific RTSP client that extends the base RTSP client with
483
+ * Apple audio streaming commands. Handles the full RAOP RTSP lifecycle:
484
+ * ANNOUNCE, SETUP, RECORD, SET_PARAMETER, FLUSH, TEARDOWN, as well as
485
+ * authentication (auth-setup, digest) and metadata/artwork publishing.
486
+ */
269
487
  var RaopRtspClient = class extends RtspClient {
488
+ /** Active-Remote identifier used for DACP remote control pairing. */
270
489
  get activeRemoteId() {
271
490
  return this.#activeRemoteId;
272
491
  }
492
+ /** DACP identifier used for remote control discovery. */
273
493
  get dacpId() {
274
494
  return this.#dacpId;
275
495
  }
496
+ /** RTSP session identifier string included in session-scoped requests. */
276
497
  get rtspSessionId() {
277
498
  return this.#rtspSessionId;
278
499
  }
500
+ /** Numeric session identifier used in the RTSP URI path. */
279
501
  get sessionId() {
280
502
  return this.#sessionId;
281
503
  }
504
+ /** RTSP URI for this session, formatted as `rtsp://<localIp>/<sessionId>`. */
282
505
  get uri() {
283
506
  return `rtsp://${this.connection.localIp}/${this.#sessionId}`;
284
507
  }
508
+ /** Local and remote IP addresses of the RTSP connection. */
285
509
  get connection() {
286
510
  return {
287
- localIp: this.#localIp,
511
+ localIp: this.localAddress,
288
512
  remoteIp: this.address
289
513
  };
290
514
  }
515
+ /** Generated Active-Remote identifier for DACP. */
291
516
  #activeRemoteId;
517
+ /** Generated DACP identifier for remote control. */
292
518
  #dacpId;
519
+ /** Generated RTSP session identifier. */
293
520
  #rtspSessionId;
521
+ /** Random numeric session identifier for the RTSP URI. */
294
522
  #sessionId;
295
- #localIp = "0.0.0.0";
523
+ /** Digest authentication credentials, set after a 401 challenge. */
296
524
  #digestInfo;
525
+ /**
526
+ * Creates a new RAOP RTSP client and generates unique session identifiers.
527
+ *
528
+ * @param context - Application context for logging and device identity.
529
+ * @param address - IP address of the RAOP receiver.
530
+ * @param port - RTSP port of the RAOP receiver.
531
+ */
297
532
  constructor(context, address, port) {
298
533
  super(context, address, port);
299
534
  this.#activeRemoteId = generateActiveRemoteId();
300
535
  this.#dacpId = generateDacpId();
301
536
  this.#rtspSessionId = generateSessionId();
302
537
  this.#sessionId = Math.floor(Math.random() * 4294967295);
303
- this.on("connect", () => {
304
- this.#localIp = "0.0.0.0";
305
- });
306
538
  }
539
+ /**
540
+ * Returns default headers included with every RTSP request, including
541
+ * DACP identifiers, user-agent, and digest authorization if available.
542
+ *
543
+ * @returns Header key-value pairs.
544
+ */
307
545
  getDefaultHeaders() {
308
546
  const headers = {
309
547
  "DACP-ID": this.#dacpId,
@@ -314,6 +552,12 @@ var RaopRtspClient = class extends RtspClient {
314
552
  if (this.#digestInfo) headers["Authorization"] = getDigestPayload("", this.uri, this.#digestInfo);
315
553
  return headers;
316
554
  }
555
+ /**
556
+ * Fetches device information from the `/info` endpoint. Returns the
557
+ * parsed plist response as a dictionary, or an empty object on failure.
558
+ *
559
+ * @returns Device info dictionary, or empty object if unavailable.
560
+ */
317
561
  async info() {
318
562
  try {
319
563
  const response = await this.exchange("GET", "/info", { allowError: true });
@@ -332,6 +576,11 @@ var RaopRtspClient = class extends RtspClient {
332
576
  return {};
333
577
  }
334
578
  }
579
+ /**
580
+ * Performs MFi-SAP authentication setup by sending a Curve25519 public
581
+ * key to `/auth-setup`. Required for AirPort Express devices that use
582
+ * MFi-SAP encryption.
583
+ */
335
584
  async authSetup() {
336
585
  const body = Buffer.concat([AUTH_SETUP_UNENCRYPTED, CURVE25519_PUB_KEY]);
337
586
  await this.exchange("POST", "/auth-setup", {
@@ -340,6 +589,17 @@ var RaopRtspClient = class extends RtspClient {
340
589
  protocol: "HTTP/1.1"
341
590
  });
342
591
  }
592
+ /**
593
+ * Sends an RTSP ANNOUNCE request with an SDP body describing the audio
594
+ * format. If the receiver responds with a 401 challenge and a password
595
+ * is provided, retries with HTTP Digest authentication.
596
+ *
597
+ * @param bytesPerChannel - Bytes per audio channel sample (e.g. 2 for 16-bit).
598
+ * @param channels - Number of audio channels.
599
+ * @param sampleRate - Audio sample rate in Hz.
600
+ * @param password - Optional password for digest authentication.
601
+ * @returns The RTSP response.
602
+ */
343
603
  async announce(bytesPerChannel, channels, sampleRate, password) {
344
604
  const body = buildAnnouncePayload({
345
605
  sessionId: this.#sessionId,
@@ -374,24 +634,60 @@ var RaopRtspClient = class extends RtspClient {
374
634
  }
375
635
  return response;
376
636
  }
637
+ /**
638
+ * Sends an RTSP SETUP request to negotiate transport parameters
639
+ * (ports, protocol) with the receiver.
640
+ *
641
+ * @param headers - Optional additional headers (e.g. Transport).
642
+ * @param body - Optional request body.
643
+ * @returns The RTSP response containing server-assigned ports in the Transport header.
644
+ */
377
645
  async setup(headers, body) {
378
646
  return await this.exchange("SETUP", this.uri, {
379
647
  headers,
380
648
  body
381
649
  });
382
650
  }
651
+ /**
652
+ * Sends an RTSP RECORD request to begin audio playback on the receiver.
653
+ * Includes RTP-Info and Range headers for stream positioning.
654
+ *
655
+ * @param headers - Optional headers (typically Range, Session, RTP-Info).
656
+ */
383
657
  async record(headers) {
384
658
  await this.exchange("RECORD", this.uri, { headers });
385
659
  }
660
+ /**
661
+ * Sends an RTSP FLUSH request to clear the receiver's audio buffer
662
+ * and reset playback to the specified RTP position.
663
+ *
664
+ * @param options - Headers including Session and RTP-Info for flush positioning.
665
+ */
386
666
  async flush(options) {
387
667
  await this.exchange("FLUSH", this.uri, { headers: options.headers });
388
668
  }
669
+ /**
670
+ * Sends a SET_PARAMETER request with a text/parameters content type.
671
+ * Used for setting volume, progress, and other scalar parameters.
672
+ *
673
+ * @param name - Parameter name (e.g. "volume", "progress").
674
+ * @param value - Parameter value as a string.
675
+ */
389
676
  async setParameter(name, value) {
390
677
  await this.exchange("SET_PARAMETER", this.uri, {
391
678
  contentType: "text/parameters",
392
679
  body: `${name}: ${value}`
393
680
  });
394
681
  }
682
+ /**
683
+ * Sends track metadata (title, artist, album, duration) to the receiver
684
+ * as DAAP-tagged data via SET_PARAMETER.
685
+ *
686
+ * @param session - RTSP session identifier.
687
+ * @param rtpseq - Current RTP sequence number for the RTP-Info header.
688
+ * @param rtptime - Current RTP timestamp for the RTP-Info header.
689
+ * @param metadata - Track metadata to send.
690
+ */
395
691
  async setMetadata(session, rtpseq, rtptime, metadata) {
396
692
  const daapData = DAAP.encodeTrackMetadata({
397
693
  title: metadata.title,
@@ -408,6 +704,15 @@ var RaopRtspClient = class extends RtspClient {
408
704
  body: daapData
409
705
  });
410
706
  }
707
+ /**
708
+ * Sends album artwork to the receiver via SET_PARAMETER. Automatically
709
+ * detects PNG images by magic bytes, defaulting to JPEG otherwise.
710
+ *
711
+ * @param session - RTSP session identifier.
712
+ * @param rtpseq - Current RTP sequence number for the RTP-Info header.
713
+ * @param rtptime - Current RTP timestamp for the RTP-Info header.
714
+ * @param artwork - Image data buffer (JPEG or PNG).
715
+ */
411
716
  async setArtwork(session, rtpseq, rtptime, artwork) {
412
717
  let contentType = "image/jpeg";
413
718
  if (artwork[0] === 137 && artwork[1] === 80) contentType = "image/png";
@@ -420,9 +725,22 @@ var RaopRtspClient = class extends RtspClient {
420
725
  body: artwork
421
726
  });
422
727
  }
728
+ /**
729
+ * Sends a feedback request to `/feedback` to keep the session alive.
730
+ * Typically called periodically during active streaming.
731
+ *
732
+ * @param allowError - Whether to suppress HTTP error responses.
733
+ * @returns The feedback response.
734
+ */
423
735
  async feedback(allowError = false) {
424
736
  return await this.exchange("POST", "/feedback", { allowError });
425
737
  }
738
+ /**
739
+ * Sends an RTSP TEARDOWN request to end the streaming session
740
+ * and release server-side resources.
741
+ *
742
+ * @param session - RTSP session identifier to tear down.
743
+ */
426
744
  async teardown(session) {
427
745
  await this.exchange("TEARDOWN", this.uri, { headers: { "Session": session } });
428
746
  }
@@ -430,31 +748,69 @@ var RaopRtspClient = class extends RtspClient {
430
748
 
431
749
  //#endregion
432
750
  //#region src/statistics.ts
751
+ /**
752
+ * Tracks real-time streaming statistics to detect when audio packet
753
+ * sending falls behind wall-clock time. Uses high-resolution monotonic
754
+ * timers to compare actual frames sent against expected frame count.
755
+ */
433
756
  var Statistics = class {
757
+ /** Audio sample rate in Hz, used to compute expected frame counts. */
434
758
  sampleRate;
759
+ /** High-resolution monotonic timestamp (nanoseconds) captured at construction. */
435
760
  startTimeNs;
761
+ /** Millisecond timestamp marking the start of the current reporting interval. */
436
762
  intervalTime;
763
+ /** Total number of audio frames sent since streaming began. */
437
764
  totalFrames = 0;
765
+ /** Number of audio frames sent within the current reporting interval. */
438
766
  intervalFrames = 0;
767
+ /**
768
+ * Creates a new Statistics tracker, capturing the current time as baseline.
769
+ *
770
+ * @param sampleRate - Audio sample rate in Hz (e.g. 44100).
771
+ */
439
772
  constructor(sampleRate) {
440
773
  this.sampleRate = sampleRate;
441
774
  this.startTimeNs = process.hrtime.bigint();
442
775
  this.intervalTime = performance.now();
443
776
  }
777
+ /**
778
+ * Computes how many audio frames should have been sent by now,
779
+ * based on elapsed wall-clock time since streaming started.
780
+ */
444
781
  get expectedFrameCount() {
445
782
  const elapsedNs = Number(process.hrtime.bigint() - this.startTimeNs);
446
783
  return Math.floor(elapsedNs / (1e9 / this.sampleRate));
447
784
  }
785
+ /**
786
+ * Number of frames the sender is lagging behind real-time.
787
+ * A positive value means packets need to be sent faster.
788
+ */
448
789
  get framesBehind() {
449
790
  return this.expectedFrameCount - this.totalFrames;
450
791
  }
792
+ /**
793
+ * Whether the current interval has accumulated at least one second
794
+ * worth of frames, indicating it is time to log and reset.
795
+ */
451
796
  get intervalCompleted() {
452
797
  return this.intervalFrames >= this.sampleRate;
453
798
  }
799
+ /**
800
+ * Records that additional audio frames have been sent.
801
+ *
802
+ * @param sentFrames - Number of frames just sent.
803
+ */
454
804
  tick(sentFrames) {
455
805
  this.totalFrames += sentFrames;
456
806
  this.intervalFrames += sentFrames;
457
807
  }
808
+ /**
809
+ * Completes the current reporting interval, resetting the interval
810
+ * frame counter and returning timing information for logging.
811
+ *
812
+ * @returns A tuple of [elapsed seconds, frames sent] for the completed interval.
813
+ */
458
814
  newInterval() {
459
815
  const endTime = performance.now();
460
816
  const diff = (endTime - this.intervalTime) / 1e3;
@@ -467,51 +823,109 @@ var Statistics = class {
467
823
 
468
824
  //#endregion
469
825
  //#region src/const.ts
826
+ /**
827
+ * Number of recently sent packets to keep in the FIFO backlog
828
+ * for retransmission upon receiver request.
829
+ */
470
830
  const PACKET_BACKLOG_SIZE = 1e3;
831
+ /**
832
+ * Fallback metadata used when the caller does not provide any
833
+ * media metadata for the audio stream.
834
+ */
471
835
  const MISSING_METADATA = {
472
836
  title: "Streaming with apple-raop",
473
837
  artist: "apple-raop",
474
838
  album: "AirPlay",
475
839
  duration: 0
476
840
  };
841
+ /**
842
+ * Empty metadata sentinel used as the default value before any
843
+ * metadata has been set on a stream.
844
+ */
477
845
  const EMPTY_METADATA = {
478
846
  title: "",
479
847
  artist: "",
480
848
  album: "",
481
849
  duration: 0
482
850
  };
851
+ /**
852
+ * Bitmask of encryption types this client supports.
853
+ * Currently supports unencrypted and MFi-SAP (AirPort Express) encryption.
854
+ */
483
855
  const SUPPORTED_ENCRYPTIONS = EncryptionType.Unencrypted | EncryptionType.MFiSAP;
484
856
 
485
857
  //#endregion
486
858
  //#region src/streamClient.ts
859
+ /**
860
+ * Core streaming engine for RAOP audio. Manages the complete audio streaming
861
+ * lifecycle: initialization (encryption/metadata negotiation, control channel
862
+ * setup), real-time PCM packet sending with timing compensation, metadata
863
+ * and artwork publishing, and teardown.
864
+ *
865
+ * Uses a Statistics tracker to maintain real-time pacing and compensates
866
+ * for slow packet sending by bursting additional packets when falling behind.
867
+ */
487
868
  var StreamClient = class extends EventEmitter {
869
+ /** Device info dictionary fetched from the receiver during initialization. */
488
870
  get info() {
489
871
  return this.#info;
490
872
  }
873
+ /**
874
+ * Current playback info snapshot, substituting fallback metadata
875
+ * if the caller provided no metadata.
876
+ */
491
877
  get playbackInfo() {
492
878
  return {
493
879
  metadata: this.#isMetadataEmpty(this.#metadata) ? MISSING_METADATA : this.#metadata,
494
880
  position: this.#streamContext.position
495
881
  };
496
882
  }
883
+ /**
884
+ * Whether MFi-SAP auth-setup is required, determined by the device's
885
+ * supported encryption types and model name (AirPort devices only).
886
+ */
497
887
  get #requiresAuthSetup() {
498
888
  const modelName = this.#properties.get("am") ?? "";
499
889
  return (this.#encryptionTypes & EncryptionType.MFiSAP) !== 0 && modelName.startsWith("AirPort");
500
890
  }
891
+ /** Application context for logging. */
501
892
  #context;
893
+ /** RAOP RTSP client for protocol commands. */
502
894
  #rtsp;
895
+ /** Shared mutable streaming state. */
503
896
  #streamContext;
897
+ /** Port configuration settings. */
504
898
  #settings;
899
+ /** Protocol abstraction for transport-level operations. */
505
900
  #protocol;
901
+ /** FIFO backlog of recently sent packets for retransmission. */
506
902
  #packetBacklog;
903
+ /** NTP timing server for clock synchronization with the receiver. */
507
904
  #timingServer;
905
+ /** UDP control channel client, created during initialization. */
508
906
  #controlClient;
907
+ /** Bitmask of receiver-supported encryption types. */
509
908
  #encryptionTypes = EncryptionType.Unknown;
909
+ /** Bitmask of receiver-supported metadata types. */
510
910
  #metadataTypes = MetadataType.NotSupported;
911
+ /** Current track metadata. */
511
912
  #metadata = EMPTY_METADATA;
913
+ /** Device info dictionary from the receiver. */
512
914
  #info = {};
915
+ /** mDNS TXT record properties for the device. */
513
916
  #properties = /* @__PURE__ */ new Map();
917
+ /** Flag controlling the streaming loop; set to false to stop. */
514
918
  #isPlaying = false;
919
+ /**
920
+ * Creates a new stream client.
921
+ *
922
+ * @param context - Application context for logging.
923
+ * @param rtsp - Connected RAOP RTSP client.
924
+ * @param streamContext - Shared mutable streaming state.
925
+ * @param protocol - Protocol handler for transport operations.
926
+ * @param settings - Port configuration settings.
927
+ * @param timingServer - NTP timing server.
928
+ */
515
929
  constructor(context, rtsp, streamContext, protocol, settings, timingServer) {
516
930
  super();
517
931
  this.#context = context;
@@ -522,10 +936,19 @@ var StreamClient = class extends EventEmitter {
522
936
  this.#packetBacklog = new PacketFifo(PACKET_BACKLOG_SIZE);
523
937
  this.#timingServer = timingServer;
524
938
  }
939
+ /**
940
+ * Closes the control channel, releasing its UDP socket.
941
+ */
525
942
  close() {
526
- this.#protocol.teardown();
527
943
  this.#controlClient?.close();
528
944
  }
945
+ /**
946
+ * Initializes the streaming session by parsing device capabilities,
947
+ * binding the control channel, fetching device info, performing
948
+ * auth-setup if required, and running the protocol SETUP handshake.
949
+ *
950
+ * @param properties - mDNS TXT record key-value pairs for the device.
951
+ */
529
952
  async initialize(properties) {
530
953
  this.#properties = properties;
531
954
  this.#encryptionTypes = getEncryptionTypes(properties);
@@ -534,7 +957,7 @@ var StreamClient = class extends EventEmitter {
534
957
  const intersection = this.#encryptionTypes & SUPPORTED_ENCRYPTIONS;
535
958
  if (!intersection || intersection === EncryptionType.Unknown) this.#context.logger.debug("No supported encryption type, continuing anyway");
536
959
  this.#updateOutputProperties(properties);
537
- this.#controlClient = new ControlClient(this.#streamContext, this.#packetBacklog);
960
+ this.#controlClient = new ControlClient(this.#context, this.#streamContext, this.#packetBacklog);
538
961
  await this.#controlClient.bind(this.#rtsp.connection.localIp, this.#settings.protocols.raop.controlPort);
539
962
  this.#context.logger.debug(`Local ports: control=${this.#controlClient.port}, timing=${this.#timingServer.port}`);
540
963
  const info = await this.#rtsp.info();
@@ -543,14 +966,36 @@ var StreamClient = class extends EventEmitter {
543
966
  if (this.#requiresAuthSetup) await this.#rtsp.authSetup();
544
967
  await this.#protocol.setup(this.#timingServer.port, this.#controlClient.port);
545
968
  }
969
+ /**
970
+ * Signals the streaming loop to stop after the current packet.
971
+ * The `sendAudio()` call will return after teardown completes.
972
+ */
546
973
  stop() {
547
974
  this.#context.logger.debug("Stopping audio playback");
548
975
  this.#isPlaying = false;
549
976
  }
977
+ /**
978
+ * Changes the playback volume on the receiver via RTSP SET_PARAMETER.
979
+ *
980
+ * @param volume - Volume level in dBFS.
981
+ */
550
982
  async setVolume(volume) {
551
983
  await this.#rtsp.setParameter("volume", String(volume));
552
984
  this.#streamContext.volume = volume;
553
985
  }
986
+ /**
987
+ * Main entry point for streaming audio. Resets the stream context,
988
+ * connects the UDP audio transport, publishes metadata/artwork/progress,
989
+ * starts the RTSP RECORD session, and enters the real-time streaming loop.
990
+ *
991
+ * On completion or error, performs full teardown: clears the packet backlog,
992
+ * sends RTSP TEARDOWN, closes the UDP transport, and emits 'stopped'.
993
+ *
994
+ * @param source - Audio source providing PCM frames to stream.
995
+ * @param metadata - Track metadata to display on the receiver.
996
+ * @param volume - Optional initial volume as a percentage (0-100), converted to dBFS.
997
+ * @throws When streaming encounters an unrecoverable error.
998
+ */
554
999
  async sendAudio(source, metadata = EMPTY_METADATA, volume) {
555
1000
  if (!this.#controlClient) throw new Error("Not initialized");
556
1001
  this.#streamContext.reset();
@@ -592,7 +1037,7 @@ var StreamClient = class extends EventEmitter {
592
1037
  await this.#streamData(source, transport);
593
1038
  } catch (err) {
594
1039
  this.#context.logger.error("An error occurred during streaming.", err);
595
- throw new Error(`An error occurred during streaming: ${err}`);
1040
+ throw new Error("An error occurred during streaming.", { cause: err });
596
1041
  } finally {
597
1042
  this.#packetBacklog.clear();
598
1043
  if (transport) {
@@ -604,6 +1049,16 @@ var StreamClient = class extends EventEmitter {
604
1049
  this.emit("stopped");
605
1050
  }
606
1051
  }
1052
+ /**
1053
+ * Real-time audio streaming loop. Reads PCM frames from the source,
1054
+ * sends them as RTP packets over UDP, and uses wall-clock timing to
1055
+ * maintain real-time pacing. When falling behind, sends compensating
1056
+ * burst packets (up to MAX_PACKETS_COMPENSATE). Logs periodic
1057
+ * throughput statistics and slow-sender warnings.
1058
+ *
1059
+ * @param source - Audio source to read frames from.
1060
+ * @param transport - UDP socket connected to the receiver's audio port.
1061
+ */
607
1062
  async #streamData(source, transport) {
608
1063
  const stats = new Statistics(this.#streamContext.sampleRate);
609
1064
  const initialTime = performance.now();
@@ -643,6 +1098,17 @@ var StreamClient = class extends EventEmitter {
643
1098
  const elapsedNs = Number(process.hrtime.bigint() - stats.startTimeNs);
644
1099
  this.#context.logger.debug(`Audio finished sending in ${(elapsedNs / 1e9).toFixed(3)}s`);
645
1100
  }
1101
+ /**
1102
+ * Reads one packet's worth of audio frames from the source and sends
1103
+ * it as an RTP packet. If the source is exhausted, sends silence
1104
+ * padding until the latency buffer is filled. Undersized frames
1105
+ * are zero-padded to the expected packet size.
1106
+ *
1107
+ * @param source - Audio source to read frames from.
1108
+ * @param firstPacket - Whether this is the first packet (uses marker payload type 0xE0).
1109
+ * @param transport - UDP socket connected to the receiver's audio port.
1110
+ * @returns Number of audio frames sent, or 0 when padding is exhausted.
1111
+ */
646
1112
  async #sendPacket(source, firstPacket, transport) {
647
1113
  if (this.#streamContext.paddingSent >= this.#streamContext.latency) return 0;
648
1114
  let frames = await source.readFrames(352);
@@ -658,9 +1124,18 @@ var StreamClient = class extends EventEmitter {
658
1124
  const [rtpseq, packet] = await this.#protocol.sendAudioPacket(transport, header, frames);
659
1125
  this.#packetBacklog.set(rtpseq, packet);
660
1126
  this.#streamContext.rtpseq = (this.#streamContext.rtpseq + 1) % 2 ** 16;
661
- this.#streamContext.headTs += Math.floor(frames.length / this.#streamContext.frameSize);
1127
+ this.#streamContext.headTs = this.#streamContext.headTs + Math.floor(frames.length / this.#streamContext.frameSize) >>> 0;
662
1128
  return Math.floor(frames.length / this.#streamContext.frameSize);
663
1129
  }
1130
+ /**
1131
+ * Sends multiple packets in a burst to compensate for falling behind
1132
+ * real-time playback. Stops early if the source is exhausted.
1133
+ *
1134
+ * @param source - Audio source to read frames from.
1135
+ * @param transport - UDP socket connected to the receiver's audio port.
1136
+ * @param count - Number of packets to send.
1137
+ * @returns A tuple of [total frames sent, whether more packets are available].
1138
+ */
664
1139
  async #sendNumberOfPackets(source, transport, count) {
665
1140
  let totalFrames = 0;
666
1141
  for (let i = 0; i < count; i++) {
@@ -670,9 +1145,22 @@ var StreamClient = class extends EventEmitter {
670
1145
  }
671
1146
  return [totalFrames, true];
672
1147
  }
1148
+ /**
1149
+ * Checks whether the given metadata has all empty/default values,
1150
+ * indicating no real metadata was provided by the caller.
1151
+ *
1152
+ * @param metadata - Metadata to check.
1153
+ * @returns True if all fields are empty or zero.
1154
+ */
673
1155
  #isMetadataEmpty(metadata) {
674
1156
  return metadata.title === "" && metadata.artist === "" && metadata.album === "" && metadata.duration === 0;
675
1157
  }
1158
+ /**
1159
+ * Updates the stream context audio format from mDNS TXT record
1160
+ * properties (sample rate, channels, bytes per channel).
1161
+ *
1162
+ * @param properties - mDNS TXT record key-value pairs.
1163
+ */
676
1164
  #updateOutputProperties(properties) {
677
1165
  const [sampleRate, channels, bytesPerChannel] = getAudioProperties(properties);
678
1166
  this.#streamContext.sampleRate = sampleRate;
@@ -684,30 +1172,58 @@ var StreamClient = class extends EventEmitter {
684
1172
 
685
1173
  //#endregion
686
1174
  //#region src/raop.ts
1175
+ /** Default audio sample rate in Hz (CD quality). */
687
1176
  const SAMPLE_RATE = 44100;
1177
+ /** Default number of audio channels (stereo). */
688
1178
  const CHANNELS = 2;
1179
+ /** Default bytes per channel sample (16-bit). */
689
1180
  const BYTES_PER_CHANNEL = 2;
1181
+ /** Number of audio frames per RTP packet. */
690
1182
  const FRAMES_PER_PACKET = 352;
1183
+ /**
1184
+ * High-level RAOP client for streaming audio to AirPlay receivers.
1185
+ * Wraps the RTSP handshake, UDP audio transport, control channel,
1186
+ * and metadata publishing into a simple stream/stop/close API.
1187
+ *
1188
+ * Instances are created via the static `create()` or `discover()` methods.
1189
+ */
691
1190
  var RaopClient = class RaopClient extends EventEmitter {
1191
+ /** Application context providing logger and device identity. */
692
1192
  get context() {
693
1193
  return this.#context;
694
1194
  }
1195
+ /** Unique identifier of the discovered RAOP device. */
695
1196
  get deviceId() {
696
1197
  return this.#discoveryResult.id;
697
1198
  }
1199
+ /** IP address of the RAOP receiver. */
698
1200
  get address() {
699
1201
  return this.#discoveryResult.address;
700
1202
  }
1203
+ /** Model name of the RAOP receiver (e.g. "AirPort Express"). */
701
1204
  get modelName() {
702
1205
  return this.#discoveryResult.modelName;
703
1206
  }
1207
+ /** Device info dictionary fetched during initialization. */
704
1208
  get info() {
705
1209
  return this.#streamClient.info;
706
1210
  }
1211
+ /** Application context for logging and identity. */
707
1212
  #context;
1213
+ /** RAOP RTSP client for protocol-level commands. */
708
1214
  #rtsp;
1215
+ /** Stream client managing audio transport and metadata. */
709
1216
  #streamClient;
1217
+ /** mDNS discovery result for this device. */
710
1218
  #discoveryResult;
1219
+ /**
1220
+ * Private constructor — use `RaopClient.create()` or `RaopClient.discover()` instead.
1221
+ *
1222
+ * @param context - Application context.
1223
+ * @param rtsp - Connected RTSP client.
1224
+ * @param streamClient - Initialized stream client.
1225
+ * @param discoveryResult - mDNS discovery result for the device.
1226
+ */
711
1227
  constructor(context, rtsp, streamClient, discoveryResult) {
712
1228
  super();
713
1229
  this.#context = context;
@@ -717,6 +1233,13 @@ var RaopClient = class RaopClient extends EventEmitter {
717
1233
  this.#streamClient.on("playing", (info) => this.emit("playing", info));
718
1234
  this.#streamClient.on("stopped", () => this.emit("stopped"));
719
1235
  }
1236
+ /**
1237
+ * Streams audio from a source to the RAOP receiver. Starts the source,
1238
+ * sends audio data over RTP, and stops the source when finished or on error.
1239
+ *
1240
+ * @param source - Audio source providing PCM frames.
1241
+ * @param options - Optional metadata and volume settings.
1242
+ */
720
1243
  async stream(source, options = {}) {
721
1244
  await source.start();
722
1245
  try {
@@ -725,16 +1248,37 @@ var RaopClient = class RaopClient extends EventEmitter {
725
1248
  await source.stop();
726
1249
  }
727
1250
  }
1251
+ /**
1252
+ * Signals the stream client to stop sending audio. The current
1253
+ * `stream()` call will complete after flushing remaining data.
1254
+ */
728
1255
  stop() {
729
1256
  this.#streamClient.stop();
730
1257
  }
1258
+ /**
1259
+ * Changes the playback volume on the receiver.
1260
+ *
1261
+ * @param volume - Volume level in dBFS.
1262
+ */
731
1263
  async setVolume(volume) {
732
1264
  await this.#streamClient.setVolume(volume);
733
1265
  }
1266
+ /**
1267
+ * Closes all connections and releases resources. Disconnects the
1268
+ * RTSP session and shuts down the control channel.
1269
+ */
734
1270
  async close() {
735
1271
  this.#streamClient.close();
736
1272
  await this.#rtsp.disconnect();
737
1273
  }
1274
+ /**
1275
+ * Creates a new RaopClient from a pre-discovered device. Connects
1276
+ * via RTSP, initializes the stream context, and negotiates transport.
1277
+ *
1278
+ * @param discoveryResult - mDNS discovery result for the target device.
1279
+ * @param timingServer - NTP timing server for clock synchronization.
1280
+ * @returns A fully initialized and connected RaopClient.
1281
+ */
738
1282
  static async create(discoveryResult, timingServer) {
739
1283
  const context = new Context(discoveryResult.id);
740
1284
  const rtsp = new RaopRtspClient(context, discoveryResult.address, discoveryResult.service.port);
@@ -749,19 +1293,49 @@ var RaopClient = class RaopClient extends EventEmitter {
749
1293
  await streamClient.initialize(properties);
750
1294
  return new RaopClient(context, rtsp, streamClient, discoveryResult);
751
1295
  }
1296
+ /**
1297
+ * Discovers a RAOP device by its device ID via mDNS, then creates
1298
+ * and returns a connected RaopClient.
1299
+ *
1300
+ * @param deviceId - Unique device identifier to search for.
1301
+ * @param timingServer - NTP timing server for clock synchronization.
1302
+ * @returns A fully initialized and connected RaopClient.
1303
+ */
752
1304
  static async discover(deviceId, timingServer) {
753
1305
  const result = await Discovery.raop().findUntil(deviceId);
754
1306
  return RaopClient.create(result, timingServer);
755
1307
  }
756
1308
  };
1309
+ /**
1310
+ * RAOP-specific implementation of the StreamProtocol interface.
1311
+ * Handles RTSP ANNOUNCE/SETUP for transport negotiation, periodic
1312
+ * feedback keepalives, and raw UDP audio packet sending.
1313
+ */
757
1314
  var RaopStreamProtocol = class {
1315
+ /** RTSP client for protocol commands. */
758
1316
  #rtsp;
1317
+ /** Shared stream context for port and format state. */
759
1318
  #streamContext;
1319
+ /** Interval timer for periodic feedback requests. */
760
1320
  #feedbackInterval;
1321
+ /**
1322
+ * Creates a new RAOP stream protocol handler.
1323
+ *
1324
+ * @param rtsp - RAOP RTSP client for sending protocol commands.
1325
+ * @param streamContext - Shared mutable streaming state.
1326
+ */
761
1327
  constructor(rtsp, streamContext) {
762
1328
  this.#rtsp = rtsp;
763
1329
  this.#streamContext = streamContext;
764
1330
  }
1331
+ /**
1332
+ * Announces the audio format and sets up the RTP transport by
1333
+ * sending RTSP ANNOUNCE and SETUP requests. Parses the server's
1334
+ * response to extract assigned server and control ports.
1335
+ *
1336
+ * @param timingPort - Local NTP timing server port.
1337
+ * @param controlPort - Local control channel port.
1338
+ */
765
1339
  async setup(timingPort, controlPort) {
766
1340
  await this.#rtsp.announce(this.#streamContext.bytesPerChannel, this.#streamContext.channels, this.#streamContext.sampleRate);
767
1341
  const transport = [
@@ -779,6 +1353,10 @@ var RaopStreamProtocol = class {
779
1353
  const controlPortMatch = transportHeader.match(/control_port=(\d+)/);
780
1354
  if (controlPortMatch) this.#streamContext.controlPort = parseInt(controlPortMatch[1], 10);
781
1355
  }
1356
+ /**
1357
+ * Starts sending periodic feedback POST requests every 2 seconds
1358
+ * to keep the RAOP session alive during streaming.
1359
+ */
782
1360
  async startFeedback() {
783
1361
  this.#feedbackInterval = setInterval(async () => {
784
1362
  try {
@@ -788,6 +1366,15 @@ var RaopStreamProtocol = class {
788
1366
  }
789
1367
  }, 2e3);
790
1368
  }
1369
+ /**
1370
+ * Sends a single audio packet over the UDP transport by concatenating
1371
+ * the RTP header with the audio payload.
1372
+ *
1373
+ * @param transport - UDP socket connected to the receiver's audio port.
1374
+ * @param header - 12-byte RTP header.
1375
+ * @param audio - Raw audio payload data.
1376
+ * @returns A tuple of [sequence number, full packet buffer] for backlog storage.
1377
+ */
791
1378
  async sendAudioPacket(transport, header, audio) {
792
1379
  const packet = Buffer.concat([header, audio]);
793
1380
  const seqno = header.readUInt16BE(2);
@@ -796,20 +1383,30 @@ var RaopStreamProtocol = class {
796
1383
  });
797
1384
  return [seqno, packet];
798
1385
  }
1386
+ /**
1387
+ * Stops the feedback interval timer, ending periodic keepalive requests.
1388
+ */
799
1389
  teardown() {
800
1390
  if (!this.#feedbackInterval) return;
801
1391
  clearInterval(this.#feedbackInterval);
802
1392
  this.#feedbackInterval = void 0;
803
1393
  }
804
1394
  };
1395
+ /**
1396
+ * Creates a fresh StreamContext with CD-quality audio defaults
1397
+ * (44100 Hz, stereo, 16-bit) and randomized RTP sequence/timestamp values.
1398
+ *
1399
+ * @returns A new mutable stream context ready for use in a streaming session.
1400
+ */
805
1401
  function createStreamContext() {
1402
+ const rtptime = Math.floor(Math.random() * 4294967295);
806
1403
  return {
807
1404
  sampleRate: SAMPLE_RATE,
808
1405
  channels: CHANNELS,
809
1406
  bytesPerChannel: BYTES_PER_CHANNEL,
810
1407
  rtpseq: Math.floor(Math.random() * 65536),
811
- rtptime: Math.floor(Math.random() * 4294967295),
812
- headTs: 0,
1408
+ rtptime,
1409
+ headTs: rtptime,
813
1410
  latency: Math.floor(SAMPLE_RATE * 2),
814
1411
  serverPort: 0,
815
1412
  controlPort: 0,