@basmilius/apple-raop 0.9.19 → 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,30 +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;
254
+ /** Local UDP port assigned after binding. */
129
255
  #localPort;
130
- 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) {
131
268
  super();
269
+ this.#appContext = appContext;
132
270
  this.#context = context;
133
271
  this.#packetBacklog = packetBacklog;
134
272
  }
273
+ /** Local UDP port the control channel is bound to, or 0 if not yet bound. */
135
274
  get port() {
136
275
  return this.#localPort ?? 0;
137
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
+ */
138
285
  async bind(localIp, port) {
139
286
  return new Promise((resolve, reject) => {
140
287
  this.#transport = createSocket("udp4");
141
288
  this.#transport.on("error", (err) => {
142
- console.error("Control connection error:", err);
289
+ this.#appContext.logger.error("[raop-control]", "Control connection error:", err);
143
290
  reject(err);
144
291
  });
145
292
  this.#transport.on("message", (data, rinfo) => {
@@ -152,6 +299,9 @@ var ControlClient = class extends EventEmitter {
152
299
  this.#transport.bind(port, localIp);
153
300
  });
154
301
  }
302
+ /**
303
+ * Stops the sync task and closes the UDP socket, releasing all resources.
304
+ */
155
305
  close() {
156
306
  this.stop();
157
307
  if (this.#transport) {
@@ -159,21 +309,41 @@ var ControlClient = class extends EventEmitter {
159
309
  this.#transport = void 0;
160
310
  }
161
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
+ */
162
320
  start(remoteAddr) {
163
321
  if (this.#syncTask) throw new Error("Already running");
322
+ this.#anchorRtp = this.#context.headTs;
323
+ this.#anchorNtp = NTP.now();
164
324
  this.#startSyncTask(remoteAddr, this.#context.controlPort);
165
325
  }
326
+ /**
327
+ * Stops the periodic sync task without closing the UDP socket.
328
+ */
166
329
  stop() {
167
330
  if (this.#syncTask) {
168
331
  clearInterval(this.#syncTask);
169
332
  this.#syncTask = void 0;
170
333
  }
171
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
+ */
172
342
  #startSyncTask(addr, port) {
173
343
  let firstPacket = true;
174
344
  const sendSync = () => {
175
345
  if (!this.#transport) return;
176
- const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate);
346
+ const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate, this.#anchorRtp, this.#anchorNtp);
177
347
  const [currentSec, currentFrac] = NTP.parts(currentTime);
178
348
  const packet = SyncPacket.encode(firstPacket ? 144 : 128, 212, 7, this.#context.headTs - this.#context.latency, currentSec, currentFrac, this.#context.headTs);
179
349
  firstPacket = false;
@@ -182,10 +352,26 @@ var ControlClient = class extends EventEmitter {
182
352
  sendSync();
183
353
  this.#syncTask = setInterval(sendSync, 1e3);
184
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
+ */
185
362
  #onMessage(data, rinfo) {
186
363
  if ((data[1] & 127) === 85) this.#retransmitLostPackets(decodeRetransmitRequest(data), rinfo);
187
- else console.debug("Received unhandled control data from", rinfo, data);
188
- }
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
+ */
189
375
  #retransmitLostPackets(request, addr) {
190
376
  for (let i = 0; i < request.lostPackets; i++) {
191
377
  const seqno = request.lostSeqno + i & 65535;
@@ -199,16 +385,32 @@ var ControlClient = class extends EventEmitter {
199
385
  packet
200
386
  ]);
201
387
  if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
202
- } 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
+ }
203
398
  }
204
399
  }
205
400
  };
206
401
 
207
402
  //#endregion
208
403
  //#region src/rtspClient.ts
404
+ /** User-Agent header value sent with all RTSP requests. */
209
405
  const USER_AGENT = "AirPlay/550.10";
406
+ /** Number of audio frames per RTP packet, used in SDP ANNOUNCE payload. */
210
407
  const FRAMES_PER_PACKET$1 = 352;
408
+ /** Single-byte flag indicating unencrypted auth-setup mode. */
211
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
+ */
212
414
  const CURVE25519_PUB_KEY = Buffer.from([
213
415
  89,
214
416
  2,
@@ -243,12 +445,27 @@ const CURVE25519_PUB_KEY = Buffer.from([
243
445
  29,
244
446
  78
245
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
+ */
246
456
  function getDigestPayload(method, uri, info) {
247
457
  const ha1 = createHash("md5").update(`${info.username}:${info.realm}:${info.password}`).digest("hex");
248
458
  const ha2 = createHash("md5").update(`${method}:${uri}`).digest("hex");
249
459
  const response = createHash("md5").update(`${ha1}:${info.nonce}:${ha2}`).digest("hex");
250
460
  return `Digest username="${info.username}", realm="${info.realm}", nonce="${info.nonce}", uri="${uri}", response="${response}"`;
251
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
+ */
252
469
  function buildAnnouncePayload(options) {
253
470
  return [
254
471
  "v=0",
@@ -261,44 +478,70 @@ function buildAnnouncePayload(options) {
261
478
  `a=fmtp:96 ${FRAMES_PER_PACKET$1} 0 ${options.bitsPerChannel} 40 10 14 ${options.channels} 255 0 0 ${options.sampleRate}`
262
479
  ].join("\r\n") + "\r\n";
263
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
+ */
264
487
  var RaopRtspClient = class extends RtspClient {
488
+ /** Active-Remote identifier used for DACP remote control pairing. */
265
489
  get activeRemoteId() {
266
490
  return this.#activeRemoteId;
267
491
  }
492
+ /** DACP identifier used for remote control discovery. */
268
493
  get dacpId() {
269
494
  return this.#dacpId;
270
495
  }
496
+ /** RTSP session identifier string included in session-scoped requests. */
271
497
  get rtspSessionId() {
272
498
  return this.#rtspSessionId;
273
499
  }
500
+ /** Numeric session identifier used in the RTSP URI path. */
274
501
  get sessionId() {
275
502
  return this.#sessionId;
276
503
  }
504
+ /** RTSP URI for this session, formatted as `rtsp://<localIp>/<sessionId>`. */
277
505
  get uri() {
278
506
  return `rtsp://${this.connection.localIp}/${this.#sessionId}`;
279
507
  }
508
+ /** Local and remote IP addresses of the RTSP connection. */
280
509
  get connection() {
281
510
  return {
282
- localIp: this.#localIp,
511
+ localIp: this.localAddress,
283
512
  remoteIp: this.address
284
513
  };
285
514
  }
515
+ /** Generated Active-Remote identifier for DACP. */
286
516
  #activeRemoteId;
517
+ /** Generated DACP identifier for remote control. */
287
518
  #dacpId;
519
+ /** Generated RTSP session identifier. */
288
520
  #rtspSessionId;
521
+ /** Random numeric session identifier for the RTSP URI. */
289
522
  #sessionId;
290
- #localIp = "0.0.0.0";
523
+ /** Digest authentication credentials, set after a 401 challenge. */
291
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
+ */
292
532
  constructor(context, address, port) {
293
533
  super(context, address, port);
294
534
  this.#activeRemoteId = generateActiveRemoteId();
295
535
  this.#dacpId = generateDacpId();
296
536
  this.#rtspSessionId = generateSessionId();
297
537
  this.#sessionId = Math.floor(Math.random() * 4294967295);
298
- this.on("connect", () => {
299
- this.#localIp = "0.0.0.0";
300
- });
301
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
+ */
302
545
  getDefaultHeaders() {
303
546
  const headers = {
304
547
  "DACP-ID": this.#dacpId,
@@ -309,6 +552,12 @@ var RaopRtspClient = class extends RtspClient {
309
552
  if (this.#digestInfo) headers["Authorization"] = getDigestPayload("", this.uri, this.#digestInfo);
310
553
  return headers;
311
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
+ */
312
561
  async info() {
313
562
  try {
314
563
  const response = await this.exchange("GET", "/info", { allowError: true });
@@ -327,6 +576,11 @@ var RaopRtspClient = class extends RtspClient {
327
576
  return {};
328
577
  }
329
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
+ */
330
584
  async authSetup() {
331
585
  const body = Buffer.concat([AUTH_SETUP_UNENCRYPTED, CURVE25519_PUB_KEY]);
332
586
  await this.exchange("POST", "/auth-setup", {
@@ -335,6 +589,17 @@ var RaopRtspClient = class extends RtspClient {
335
589
  protocol: "HTTP/1.1"
336
590
  });
337
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
+ */
338
603
  async announce(bytesPerChannel, channels, sampleRate, password) {
339
604
  const body = buildAnnouncePayload({
340
605
  sessionId: this.#sessionId,
@@ -369,24 +634,60 @@ var RaopRtspClient = class extends RtspClient {
369
634
  }
370
635
  return response;
371
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
+ */
372
645
  async setup(headers, body) {
373
646
  return await this.exchange("SETUP", this.uri, {
374
647
  headers,
375
648
  body
376
649
  });
377
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
+ */
378
657
  async record(headers) {
379
658
  await this.exchange("RECORD", this.uri, { headers });
380
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
+ */
381
666
  async flush(options) {
382
667
  await this.exchange("FLUSH", this.uri, { headers: options.headers });
383
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
+ */
384
676
  async setParameter(name, value) {
385
677
  await this.exchange("SET_PARAMETER", this.uri, {
386
678
  contentType: "text/parameters",
387
679
  body: `${name}: ${value}`
388
680
  });
389
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
+ */
390
691
  async setMetadata(session, rtpseq, rtptime, metadata) {
391
692
  const daapData = DAAP.encodeTrackMetadata({
392
693
  title: metadata.title,
@@ -403,6 +704,15 @@ var RaopRtspClient = class extends RtspClient {
403
704
  body: daapData
404
705
  });
405
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
+ */
406
716
  async setArtwork(session, rtpseq, rtptime, artwork) {
407
717
  let contentType = "image/jpeg";
408
718
  if (artwork[0] === 137 && artwork[1] === 80) contentType = "image/png";
@@ -415,9 +725,22 @@ var RaopRtspClient = class extends RtspClient {
415
725
  body: artwork
416
726
  });
417
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
+ */
418
735
  async feedback(allowError = false) {
419
736
  return await this.exchange("POST", "/feedback", { allowError });
420
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
+ */
421
744
  async teardown(session) {
422
745
  await this.exchange("TEARDOWN", this.uri, { headers: { "Session": session } });
423
746
  }
@@ -425,31 +748,69 @@ var RaopRtspClient = class extends RtspClient {
425
748
 
426
749
  //#endregion
427
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
+ */
428
756
  var Statistics = class {
757
+ /** Audio sample rate in Hz, used to compute expected frame counts. */
429
758
  sampleRate;
759
+ /** High-resolution monotonic timestamp (nanoseconds) captured at construction. */
430
760
  startTimeNs;
761
+ /** Millisecond timestamp marking the start of the current reporting interval. */
431
762
  intervalTime;
763
+ /** Total number of audio frames sent since streaming began. */
432
764
  totalFrames = 0;
765
+ /** Number of audio frames sent within the current reporting interval. */
433
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
+ */
434
772
  constructor(sampleRate) {
435
773
  this.sampleRate = sampleRate;
436
774
  this.startTimeNs = process.hrtime.bigint();
437
775
  this.intervalTime = performance.now();
438
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
+ */
439
781
  get expectedFrameCount() {
440
782
  const elapsedNs = Number(process.hrtime.bigint() - this.startTimeNs);
441
783
  return Math.floor(elapsedNs / (1e9 / this.sampleRate));
442
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
+ */
443
789
  get framesBehind() {
444
790
  return this.expectedFrameCount - this.totalFrames;
445
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
+ */
446
796
  get intervalCompleted() {
447
797
  return this.intervalFrames >= this.sampleRate;
448
798
  }
799
+ /**
800
+ * Records that additional audio frames have been sent.
801
+ *
802
+ * @param sentFrames - Number of frames just sent.
803
+ */
449
804
  tick(sentFrames) {
450
805
  this.totalFrames += sentFrames;
451
806
  this.intervalFrames += sentFrames;
452
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
+ */
453
814
  newInterval() {
454
815
  const endTime = performance.now();
455
816
  const diff = (endTime - this.intervalTime) / 1e3;
@@ -462,51 +823,109 @@ var Statistics = class {
462
823
 
463
824
  //#endregion
464
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
+ */
465
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
+ */
466
835
  const MISSING_METADATA = {
467
836
  title: "Streaming with apple-raop",
468
837
  artist: "apple-raop",
469
838
  album: "AirPlay",
470
839
  duration: 0
471
840
  };
841
+ /**
842
+ * Empty metadata sentinel used as the default value before any
843
+ * metadata has been set on a stream.
844
+ */
472
845
  const EMPTY_METADATA = {
473
846
  title: "",
474
847
  artist: "",
475
848
  album: "",
476
849
  duration: 0
477
850
  };
851
+ /**
852
+ * Bitmask of encryption types this client supports.
853
+ * Currently supports unencrypted and MFi-SAP (AirPort Express) encryption.
854
+ */
478
855
  const SUPPORTED_ENCRYPTIONS = EncryptionType.Unencrypted | EncryptionType.MFiSAP;
479
856
 
480
857
  //#endregion
481
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
+ */
482
868
  var StreamClient = class extends EventEmitter {
869
+ /** Device info dictionary fetched from the receiver during initialization. */
483
870
  get info() {
484
871
  return this.#info;
485
872
  }
873
+ /**
874
+ * Current playback info snapshot, substituting fallback metadata
875
+ * if the caller provided no metadata.
876
+ */
486
877
  get playbackInfo() {
487
878
  return {
488
879
  metadata: this.#isMetadataEmpty(this.#metadata) ? MISSING_METADATA : this.#metadata,
489
880
  position: this.#streamContext.position
490
881
  };
491
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
+ */
492
887
  get #requiresAuthSetup() {
493
888
  const modelName = this.#properties.get("am") ?? "";
494
889
  return (this.#encryptionTypes & EncryptionType.MFiSAP) !== 0 && modelName.startsWith("AirPort");
495
890
  }
891
+ /** Application context for logging. */
496
892
  #context;
893
+ /** RAOP RTSP client for protocol commands. */
497
894
  #rtsp;
895
+ /** Shared mutable streaming state. */
498
896
  #streamContext;
897
+ /** Port configuration settings. */
499
898
  #settings;
899
+ /** Protocol abstraction for transport-level operations. */
500
900
  #protocol;
901
+ /** FIFO backlog of recently sent packets for retransmission. */
501
902
  #packetBacklog;
903
+ /** NTP timing server for clock synchronization with the receiver. */
502
904
  #timingServer;
905
+ /** UDP control channel client, created during initialization. */
503
906
  #controlClient;
907
+ /** Bitmask of receiver-supported encryption types. */
504
908
  #encryptionTypes = EncryptionType.Unknown;
909
+ /** Bitmask of receiver-supported metadata types. */
505
910
  #metadataTypes = MetadataType.NotSupported;
911
+ /** Current track metadata. */
506
912
  #metadata = EMPTY_METADATA;
913
+ /** Device info dictionary from the receiver. */
507
914
  #info = {};
915
+ /** mDNS TXT record properties for the device. */
508
916
  #properties = /* @__PURE__ */ new Map();
917
+ /** Flag controlling the streaming loop; set to false to stop. */
509
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
+ */
510
929
  constructor(context, rtsp, streamContext, protocol, settings, timingServer) {
511
930
  super();
512
931
  this.#context = context;
@@ -517,9 +936,19 @@ var StreamClient = class extends EventEmitter {
517
936
  this.#packetBacklog = new PacketFifo(PACKET_BACKLOG_SIZE);
518
937
  this.#timingServer = timingServer;
519
938
  }
939
+ /**
940
+ * Closes the control channel, releasing its UDP socket.
941
+ */
520
942
  close() {
521
943
  this.#controlClient?.close();
522
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
+ */
523
952
  async initialize(properties) {
524
953
  this.#properties = properties;
525
954
  this.#encryptionTypes = getEncryptionTypes(properties);
@@ -528,7 +957,7 @@ var StreamClient = class extends EventEmitter {
528
957
  const intersection = this.#encryptionTypes & SUPPORTED_ENCRYPTIONS;
529
958
  if (!intersection || intersection === EncryptionType.Unknown) this.#context.logger.debug("No supported encryption type, continuing anyway");
530
959
  this.#updateOutputProperties(properties);
531
- this.#controlClient = new ControlClient(this.#streamContext, this.#packetBacklog);
960
+ this.#controlClient = new ControlClient(this.#context, this.#streamContext, this.#packetBacklog);
532
961
  await this.#controlClient.bind(this.#rtsp.connection.localIp, this.#settings.protocols.raop.controlPort);
533
962
  this.#context.logger.debug(`Local ports: control=${this.#controlClient.port}, timing=${this.#timingServer.port}`);
534
963
  const info = await this.#rtsp.info();
@@ -537,14 +966,36 @@ var StreamClient = class extends EventEmitter {
537
966
  if (this.#requiresAuthSetup) await this.#rtsp.authSetup();
538
967
  await this.#protocol.setup(this.#timingServer.port, this.#controlClient.port);
539
968
  }
969
+ /**
970
+ * Signals the streaming loop to stop after the current packet.
971
+ * The `sendAudio()` call will return after teardown completes.
972
+ */
540
973
  stop() {
541
974
  this.#context.logger.debug("Stopping audio playback");
542
975
  this.#isPlaying = false;
543
976
  }
977
+ /**
978
+ * Changes the playback volume on the receiver via RTSP SET_PARAMETER.
979
+ *
980
+ * @param volume - Volume level in dBFS.
981
+ */
544
982
  async setVolume(volume) {
545
983
  await this.#rtsp.setParameter("volume", String(volume));
546
984
  this.#streamContext.volume = volume;
547
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
+ */
548
999
  async sendAudio(source, metadata = EMPTY_METADATA, volume) {
549
1000
  if (!this.#controlClient) throw new Error("Not initialized");
550
1001
  this.#streamContext.reset();
@@ -598,6 +1049,16 @@ var StreamClient = class extends EventEmitter {
598
1049
  this.emit("stopped");
599
1050
  }
600
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
+ */
601
1062
  async #streamData(source, transport) {
602
1063
  const stats = new Statistics(this.#streamContext.sampleRate);
603
1064
  const initialTime = performance.now();
@@ -637,6 +1098,17 @@ var StreamClient = class extends EventEmitter {
637
1098
  const elapsedNs = Number(process.hrtime.bigint() - stats.startTimeNs);
638
1099
  this.#context.logger.debug(`Audio finished sending in ${(elapsedNs / 1e9).toFixed(3)}s`);
639
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
+ */
640
1112
  async #sendPacket(source, firstPacket, transport) {
641
1113
  if (this.#streamContext.paddingSent >= this.#streamContext.latency) return 0;
642
1114
  let frames = await source.readFrames(352);
@@ -652,9 +1124,18 @@ var StreamClient = class extends EventEmitter {
652
1124
  const [rtpseq, packet] = await this.#protocol.sendAudioPacket(transport, header, frames);
653
1125
  this.#packetBacklog.set(rtpseq, packet);
654
1126
  this.#streamContext.rtpseq = (this.#streamContext.rtpseq + 1) % 2 ** 16;
655
- 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;
656
1128
  return Math.floor(frames.length / this.#streamContext.frameSize);
657
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
+ */
658
1139
  async #sendNumberOfPackets(source, transport, count) {
659
1140
  let totalFrames = 0;
660
1141
  for (let i = 0; i < count; i++) {
@@ -664,9 +1145,22 @@ var StreamClient = class extends EventEmitter {
664
1145
  }
665
1146
  return [totalFrames, true];
666
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
+ */
667
1155
  #isMetadataEmpty(metadata) {
668
1156
  return metadata.title === "" && metadata.artist === "" && metadata.album === "" && metadata.duration === 0;
669
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
+ */
670
1164
  #updateOutputProperties(properties) {
671
1165
  const [sampleRate, channels, bytesPerChannel] = getAudioProperties(properties);
672
1166
  this.#streamContext.sampleRate = sampleRate;
@@ -678,30 +1172,58 @@ var StreamClient = class extends EventEmitter {
678
1172
 
679
1173
  //#endregion
680
1174
  //#region src/raop.ts
1175
+ /** Default audio sample rate in Hz (CD quality). */
681
1176
  const SAMPLE_RATE = 44100;
1177
+ /** Default number of audio channels (stereo). */
682
1178
  const CHANNELS = 2;
1179
+ /** Default bytes per channel sample (16-bit). */
683
1180
  const BYTES_PER_CHANNEL = 2;
1181
+ /** Number of audio frames per RTP packet. */
684
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
+ */
685
1190
  var RaopClient = class RaopClient extends EventEmitter {
1191
+ /** Application context providing logger and device identity. */
686
1192
  get context() {
687
1193
  return this.#context;
688
1194
  }
1195
+ /** Unique identifier of the discovered RAOP device. */
689
1196
  get deviceId() {
690
1197
  return this.#discoveryResult.id;
691
1198
  }
1199
+ /** IP address of the RAOP receiver. */
692
1200
  get address() {
693
1201
  return this.#discoveryResult.address;
694
1202
  }
1203
+ /** Model name of the RAOP receiver (e.g. "AirPort Express"). */
695
1204
  get modelName() {
696
1205
  return this.#discoveryResult.modelName;
697
1206
  }
1207
+ /** Device info dictionary fetched during initialization. */
698
1208
  get info() {
699
1209
  return this.#streamClient.info;
700
1210
  }
1211
+ /** Application context for logging and identity. */
701
1212
  #context;
1213
+ /** RAOP RTSP client for protocol-level commands. */
702
1214
  #rtsp;
1215
+ /** Stream client managing audio transport and metadata. */
703
1216
  #streamClient;
1217
+ /** mDNS discovery result for this device. */
704
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
+ */
705
1227
  constructor(context, rtsp, streamClient, discoveryResult) {
706
1228
  super();
707
1229
  this.#context = context;
@@ -711,6 +1233,13 @@ var RaopClient = class RaopClient extends EventEmitter {
711
1233
  this.#streamClient.on("playing", (info) => this.emit("playing", info));
712
1234
  this.#streamClient.on("stopped", () => this.emit("stopped"));
713
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
+ */
714
1243
  async stream(source, options = {}) {
715
1244
  await source.start();
716
1245
  try {
@@ -719,16 +1248,37 @@ var RaopClient = class RaopClient extends EventEmitter {
719
1248
  await source.stop();
720
1249
  }
721
1250
  }
1251
+ /**
1252
+ * Signals the stream client to stop sending audio. The current
1253
+ * `stream()` call will complete after flushing remaining data.
1254
+ */
722
1255
  stop() {
723
1256
  this.#streamClient.stop();
724
1257
  }
1258
+ /**
1259
+ * Changes the playback volume on the receiver.
1260
+ *
1261
+ * @param volume - Volume level in dBFS.
1262
+ */
725
1263
  async setVolume(volume) {
726
1264
  await this.#streamClient.setVolume(volume);
727
1265
  }
1266
+ /**
1267
+ * Closes all connections and releases resources. Disconnects the
1268
+ * RTSP session and shuts down the control channel.
1269
+ */
728
1270
  async close() {
729
1271
  this.#streamClient.close();
730
1272
  await this.#rtsp.disconnect();
731
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
+ */
732
1282
  static async create(discoveryResult, timingServer) {
733
1283
  const context = new Context(discoveryResult.id);
734
1284
  const rtsp = new RaopRtspClient(context, discoveryResult.address, discoveryResult.service.port);
@@ -743,19 +1293,49 @@ var RaopClient = class RaopClient extends EventEmitter {
743
1293
  await streamClient.initialize(properties);
744
1294
  return new RaopClient(context, rtsp, streamClient, discoveryResult);
745
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
+ */
746
1304
  static async discover(deviceId, timingServer) {
747
1305
  const result = await Discovery.raop().findUntil(deviceId);
748
1306
  return RaopClient.create(result, timingServer);
749
1307
  }
750
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
+ */
751
1314
  var RaopStreamProtocol = class {
1315
+ /** RTSP client for protocol commands. */
752
1316
  #rtsp;
1317
+ /** Shared stream context for port and format state. */
753
1318
  #streamContext;
1319
+ /** Interval timer for periodic feedback requests. */
754
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
+ */
755
1327
  constructor(rtsp, streamContext) {
756
1328
  this.#rtsp = rtsp;
757
1329
  this.#streamContext = streamContext;
758
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
+ */
759
1339
  async setup(timingPort, controlPort) {
760
1340
  await this.#rtsp.announce(this.#streamContext.bytesPerChannel, this.#streamContext.channels, this.#streamContext.sampleRate);
761
1341
  const transport = [
@@ -773,6 +1353,10 @@ var RaopStreamProtocol = class {
773
1353
  const controlPortMatch = transportHeader.match(/control_port=(\d+)/);
774
1354
  if (controlPortMatch) this.#streamContext.controlPort = parseInt(controlPortMatch[1], 10);
775
1355
  }
1356
+ /**
1357
+ * Starts sending periodic feedback POST requests every 2 seconds
1358
+ * to keep the RAOP session alive during streaming.
1359
+ */
776
1360
  async startFeedback() {
777
1361
  this.#feedbackInterval = setInterval(async () => {
778
1362
  try {
@@ -782,6 +1366,15 @@ var RaopStreamProtocol = class {
782
1366
  }
783
1367
  }, 2e3);
784
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
+ */
785
1378
  async sendAudioPacket(transport, header, audio) {
786
1379
  const packet = Buffer.concat([header, audio]);
787
1380
  const seqno = header.readUInt16BE(2);
@@ -790,20 +1383,30 @@ var RaopStreamProtocol = class {
790
1383
  });
791
1384
  return [seqno, packet];
792
1385
  }
1386
+ /**
1387
+ * Stops the feedback interval timer, ending periodic keepalive requests.
1388
+ */
793
1389
  teardown() {
794
1390
  if (!this.#feedbackInterval) return;
795
1391
  clearInterval(this.#feedbackInterval);
796
1392
  this.#feedbackInterval = void 0;
797
1393
  }
798
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
+ */
799
1401
  function createStreamContext() {
1402
+ const rtptime = Math.floor(Math.random() * 4294967295);
800
1403
  return {
801
1404
  sampleRate: SAMPLE_RATE,
802
1405
  channels: CHANNELS,
803
1406
  bytesPerChannel: BYTES_PER_CHANNEL,
804
1407
  rtpseq: Math.floor(Math.random() * 65536),
805
- rtptime: Math.floor(Math.random() * 4294967295),
806
- headTs: 0,
1408
+ rtptime,
1409
+ headTs: rtptime,
807
1410
  latency: Math.floor(SAMPLE_RATE * 2),
808
1411
  serverPort: 0,
809
1412
  controlPort: 0,