@corewire/airplay 0.0.0 → 1.0.0-beta.1

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/raop.mjs ADDED
@@ -0,0 +1,1248 @@
1
+ import { i as AudioSourceSession, n as generateDacpId, r as generateSessionId, t as generateActiveRemoteId } from "./identifiers-CsXXbYkW.mjs";
2
+ import { a as AUDIO_BYTES_PER_CHANNEL, o as AUDIO_CHANNELS, s as AUDIO_SAMPLE_RATE, t as RtspClient } from "./rtsp-BWFaYW5H.mjs";
3
+ import { createHash } from "node:crypto";
4
+ import { EventEmitter } from "node:events";
5
+ import { ConnectionClosedError, PlaybackError, waitFor } from "@corewire/core";
6
+ import { DAAP, NTP, Plist } from "@corewire/core/encoding";
7
+ import { Context } from "@corewire/core/transport";
8
+ import { createSocket } from "node:dgram";
9
+ import { Discovery } from "@corewire/core/discovery";
10
+
11
+ //#region src/raop/types.ts
12
+ /** Encryption types a RAOP receiver supports, from the `et` TXT field. */
13
+ let EncryptionType = /* @__PURE__ */ function(EncryptionType) {
14
+ /** The TXT record has no encryption field. */
15
+ EncryptionType[EncryptionType["Unknown"] = 0] = "Unknown";
16
+ EncryptionType[EncryptionType["Unencrypted"] = 1] = "Unencrypted";
17
+ /** The receiver supports MFi-SAP encryption, as the AirPort Express does. */
18
+ EncryptionType[EncryptionType["MFiSAP"] = 2] = "MFiSAP";
19
+ return EncryptionType;
20
+ }({});
21
+ /** Metadata types a RAOP receiver supports, from the `md` TXT field. */
22
+ let MetadataType = /* @__PURE__ */ function(MetadataType) {
23
+ MetadataType[MetadataType["NotSupported"] = 0] = "NotSupported";
24
+ /** The receiver shows title, artist and album. */
25
+ MetadataType[MetadataType["Text"] = 1] = "Text";
26
+ MetadataType[MetadataType["Artwork"] = 2] = "Artwork";
27
+ /** The receiver shows progress and duration. */
28
+ MetadataType[MetadataType["Progress"] = 4] = "Progress";
29
+ return MetadataType;
30
+ }({});
31
+
32
+ //#endregion
33
+ //#region src/raop/const.ts
34
+ /** Number of sent packets kept for retransmission. */
35
+ const PACKET_BACKLOG_SIZE = 1e3;
36
+ /** Metadata for a stream without metadata from the caller. */
37
+ const MISSING_METADATA = {
38
+ title: "Streaming with CoreWire",
39
+ artist: "CoreWire",
40
+ album: "AirPlay",
41
+ duration: 0
42
+ };
43
+ /** Metadata of a stream before any metadata is set. */
44
+ const EMPTY_METADATA = {
45
+ title: "",
46
+ artist: "",
47
+ album: "",
48
+ duration: 0
49
+ };
50
+ /** Encryption types this client supports: unencrypted and MFi-SAP (AirPort Express). */
51
+ const SUPPORTED_ENCRYPTIONS = 1 | 2;
52
+
53
+ //#endregion
54
+ //#region src/raop/rtspClient.ts
55
+ const USER_AGENT = "AirPlay/550.10";
56
+ /** Flag byte for unencrypted auth-setup mode. */
57
+ const AUTH_SETUP_UNENCRYPTED = Buffer.from([1]);
58
+ /** Static Curve25519 public key for the MFi-SAP `/auth-setup` of an AirPort Express. */
59
+ const CURVE25519_PUB_KEY = Buffer.from([
60
+ 89,
61
+ 2,
62
+ 237,
63
+ 233,
64
+ 13,
65
+ 78,
66
+ 242,
67
+ 189,
68
+ 76,
69
+ 182,
70
+ 138,
71
+ 99,
72
+ 48,
73
+ 3,
74
+ 130,
75
+ 7,
76
+ 169,
77
+ 77,
78
+ 189,
79
+ 80,
80
+ 216,
81
+ 170,
82
+ 70,
83
+ 91,
84
+ 93,
85
+ 140,
86
+ 1,
87
+ 42,
88
+ 12,
89
+ 126,
90
+ 29,
91
+ 78
92
+ ]);
93
+ /**
94
+ * Computes an MD5 HTTP Digest authorization header value.
95
+ *
96
+ * @param method - RTSP method; empty for the default headers.
97
+ */
98
+ function getDigestPayload(method, uri, info) {
99
+ const ha1 = createHash("md5").update(`${info.username}:${info.realm}:${info.password}`).digest("hex");
100
+ const ha2 = createHash("md5").update(`${method}:${uri}`).digest("hex");
101
+ const response = createHash("md5").update(`${ha1}:${info.nonce}:${ha2}`).digest("hex");
102
+ return `Digest username="${info.username}", realm="${info.realm}", nonce="${info.nonce}", uri="${uri}", response="${response}"`;
103
+ }
104
+ /** Builds the SDP body of the RTSP ANNOUNCE request, with CRLF line endings. */
105
+ function buildAnnouncePayload(options) {
106
+ return [
107
+ "v=0",
108
+ `o=iTunes ${options.sessionId} 0 IN IP4 ${options.localIp}`,
109
+ "s=iTunes",
110
+ `c=IN IP4 ${options.remoteIp}`,
111
+ "t=0 0",
112
+ "m=audio 0 RTP/AVP 96",
113
+ `a=rtpmap:96 L16/${options.sampleRate}/${options.channels}`,
114
+ `a=fmtp:96 ${352} 0 ${options.bitsPerChannel} 40 10 14 ${options.channels} 255 0 0 ${options.sampleRate}`
115
+ ].join("\r\n") + "\r\n";
116
+ }
117
+ /**
118
+ * RTSP client for the RAOP commands: ANNOUNCE, SETUP, RECORD, SET_PARAMETER,
119
+ * FLUSH and TEARDOWN, plus auth-setup, digest authentication, metadata and
120
+ * artwork.
121
+ */
122
+ var RaopRtspClient = class extends RtspClient {
123
+ /** Active-Remote identifier for DACP remote control. */
124
+ get activeRemoteId() {
125
+ return this.#activeRemoteId;
126
+ }
127
+ /** DACP identifier for remote control discovery. */
128
+ get dacpId() {
129
+ return this.#dacpId;
130
+ }
131
+ /** RTSP session identifier for session-scoped requests. */
132
+ get rtspSessionId() {
133
+ return this.#rtspSessionId;
134
+ }
135
+ /** Numeric session identifier in the RTSP URI path. */
136
+ get sessionId() {
137
+ return this.#sessionId;
138
+ }
139
+ /** RTSP URI of this session, in the form `rtsp://<localIp>/<sessionId>`. */
140
+ get uri() {
141
+ return `rtsp://${this.connection.localIp}/${this.#sessionId}`;
142
+ }
143
+ get connection() {
144
+ return {
145
+ localIp: this.localAddress,
146
+ remoteIp: this.address
147
+ };
148
+ }
149
+ #activeRemoteId;
150
+ #dacpId;
151
+ #rtspSessionId;
152
+ #sessionId;
153
+ /** Digest credentials, set after a 401 challenge. */
154
+ #digestInfo;
155
+ constructor(context, address, port) {
156
+ super(context, address, port);
157
+ this.#activeRemoteId = generateActiveRemoteId();
158
+ this.#dacpId = generateDacpId();
159
+ this.#rtspSessionId = generateSessionId();
160
+ this.#sessionId = Math.floor(Math.random() * 4294967295);
161
+ }
162
+ /** Headers on every request: the DACP identifiers, User-Agent and, after a challenge, digest authorization. */
163
+ getDefaultHeaders() {
164
+ const headers = {
165
+ "DACP-ID": this.#dacpId,
166
+ "Active-Remote": this.#activeRemoteId,
167
+ "Client-Instance": this.#dacpId,
168
+ "User-Agent": USER_AGENT
169
+ };
170
+ if (this.#digestInfo) headers["Authorization"] = getDigestPayload("", this.uri, this.#digestInfo);
171
+ return headers;
172
+ }
173
+ /**
174
+ * Fetches the device info plist from `/info`.
175
+ *
176
+ * @returns The info dictionary, or an empty object on failure.
177
+ */
178
+ async info() {
179
+ try {
180
+ const response = await this.exchange("GET", "/info", { allowError: true });
181
+ if (!response.ok) return {};
182
+ const buffer = Buffer.from(await response.arrayBuffer());
183
+ if (buffer.length === 0) return {};
184
+ try {
185
+ return Plist.parse(buffer.buffer);
186
+ } catch (err) {
187
+ this.context.logger.warn("[raop-rtsp]", "Failed to parse info plist", err);
188
+ return {};
189
+ }
190
+ } catch (err) {
191
+ this.context.logger.warn("[raop-rtsp]", "Failed to get device info", err);
192
+ return {};
193
+ }
194
+ }
195
+ /**
196
+ * Sends the Curve25519 public key to `/auth-setup`, which an AirPort
197
+ * Express with MFi-SAP encryption requires.
198
+ */
199
+ async authSetup() {
200
+ const body = Buffer.concat([AUTH_SETUP_UNENCRYPTED, CURVE25519_PUB_KEY]);
201
+ await this.exchange("POST", "/auth-setup", {
202
+ contentType: "application/octet-stream",
203
+ body,
204
+ protocol: "HTTP/1.1"
205
+ });
206
+ }
207
+ /**
208
+ * Sends ANNOUNCE with an SDP body for the audio format. On a 401
209
+ * challenge with a password given, it retries with HTTP Digest
210
+ * authentication.
211
+ *
212
+ * @param bytesPerChannel - Bytes per sample per channel, 2 for 16-bit.
213
+ * @param sampleRate - Sample rate in Hz.
214
+ */
215
+ async announce(bytesPerChannel, channels, sampleRate, password) {
216
+ const body = buildAnnouncePayload({
217
+ sessionId: this.#sessionId,
218
+ localIp: this.connection.localIp,
219
+ remoteIp: this.connection.remoteIp,
220
+ bitsPerChannel: 8 * bytesPerChannel,
221
+ channels,
222
+ sampleRate
223
+ });
224
+ let response = await this.exchange("ANNOUNCE", this.uri, {
225
+ contentType: "application/sdp",
226
+ body,
227
+ allowError: !!password
228
+ });
229
+ if (response.status === 401 && password) {
230
+ const wwwAuthenticate = response.headers.get("www-authenticate");
231
+ if (wwwAuthenticate) {
232
+ const parts = wwwAuthenticate.split("\"");
233
+ if (parts.length >= 5) {
234
+ this.#digestInfo = {
235
+ username: "apple-protocols",
236
+ realm: parts[1],
237
+ password,
238
+ nonce: parts[3]
239
+ };
240
+ response = await this.exchange("ANNOUNCE", this.uri, {
241
+ contentType: "application/sdp",
242
+ body
243
+ });
244
+ }
245
+ }
246
+ }
247
+ return response;
248
+ }
249
+ /**
250
+ * Sends SETUP to negotiate the transport (ports, protocol).
251
+ *
252
+ * @param headers - Extra headers, for example Transport.
253
+ * @returns The response, with the receiver's ports in its Transport header.
254
+ */
255
+ async setup(headers, body) {
256
+ return await this.exchange("SETUP", this.uri, {
257
+ headers,
258
+ body
259
+ });
260
+ }
261
+ /**
262
+ * Sends RECORD to start playback on the receiver.
263
+ *
264
+ * @param headers - Usually Range, Session and RTP-Info.
265
+ */
266
+ async record(headers) {
267
+ await this.exchange("RECORD", this.uri, { headers });
268
+ }
269
+ /**
270
+ * Sends FLUSH to clear the receiver's audio buffer and move playback to
271
+ * the RTP position in the headers.
272
+ *
273
+ * @param options - Headers with Session and RTP-Info.
274
+ */
275
+ async flush(options) {
276
+ await this.exchange("FLUSH", this.uri, { headers: options.headers });
277
+ }
278
+ /**
279
+ * Sends a `text/parameters` SET_PARAMETER, for volume, progress and other
280
+ * scalar values.
281
+ *
282
+ * @param name - For example `volume` or `progress`.
283
+ */
284
+ async setParameter(name, value) {
285
+ await this.exchange("SET_PARAMETER", this.uri, {
286
+ contentType: "text/parameters",
287
+ body: `${name}: ${value}`
288
+ });
289
+ }
290
+ /**
291
+ * Sends title, artist, album and duration as DAAP-tagged SET_PARAMETER.
292
+ *
293
+ * @param rtpseq - RTP sequence number for the RTP-Info header.
294
+ * @param rtptime - RTP timestamp for the RTP-Info header.
295
+ */
296
+ async setMetadata(session, rtpseq, rtptime, metadata) {
297
+ const daapData = DAAP.encodeTrackMetadata({
298
+ title: metadata.title,
299
+ artist: metadata.artist,
300
+ album: metadata.album,
301
+ duration: metadata.duration
302
+ });
303
+ await this.exchange("SET_PARAMETER", this.uri, {
304
+ contentType: "application/x-dmap-tagged",
305
+ headers: {
306
+ "Session": session,
307
+ "RTP-Info": `seq=${rtpseq};rtptime=${rtptime}`
308
+ },
309
+ body: daapData
310
+ });
311
+ }
312
+ /**
313
+ * Sends album artwork as SET_PARAMETER. The magic bytes tell PNG apart;
314
+ * anything else goes as JPEG.
315
+ *
316
+ * @param rtpseq - RTP sequence number for the RTP-Info header.
317
+ * @param rtptime - RTP timestamp for the RTP-Info header.
318
+ */
319
+ async setArtwork(session, rtpseq, rtptime, artwork) {
320
+ let contentType = "image/jpeg";
321
+ if (artwork[0] === 137 && artwork[1] === 80) contentType = "image/png";
322
+ await this.exchange("SET_PARAMETER", this.uri, {
323
+ contentType,
324
+ headers: {
325
+ "Session": session,
326
+ "RTP-Info": `seq=${rtpseq};rtptime=${rtptime}`
327
+ },
328
+ body: artwork
329
+ });
330
+ }
331
+ /**
332
+ * Posts to `/feedback`, which keeps the session alive during streaming.
333
+ *
334
+ * @param allowError - Resolves on an HTTP error status instead of throwing.
335
+ */
336
+ async feedback(allowError = false) {
337
+ return await this.exchange("POST", "/feedback", { allowError });
338
+ }
339
+ /** Sends TEARDOWN to end the streaming session. */
340
+ async teardown(session) {
341
+ await this.exchange("TEARDOWN", this.uri, { headers: { "Session": session } });
342
+ }
343
+ };
344
+
345
+ //#endregion
346
+ //#region src/raop/packets.ts
347
+ /**
348
+ * Fixed-size FIFO of recently sent RTP audio packets, for the receiver's
349
+ * retransmit requests.
350
+ */
351
+ var PacketFifo = class {
352
+ #maxSize;
353
+ #packets = /* @__PURE__ */ new Map();
354
+ /** Sequence numbers in insertion order, for eviction. */
355
+ #order = [];
356
+ constructor(maxSize) {
357
+ this.#maxSize = maxSize;
358
+ }
359
+ get(seqno) {
360
+ return this.#packets.get(seqno);
361
+ }
362
+ /**
363
+ * Stores a packet unless its sequence number is already present, and
364
+ * evicts the oldest packets past the maximum size.
365
+ *
366
+ * @param packet - Full RTP packet including the header.
367
+ */
368
+ set(seqno, packet) {
369
+ if (this.#packets.has(seqno)) return;
370
+ this.#packets.set(seqno, packet);
371
+ this.#order.push(seqno);
372
+ while (this.#order.length > this.#maxSize) {
373
+ const oldest = this.#order.shift();
374
+ if (oldest !== void 0) this.#packets.delete(oldest);
375
+ }
376
+ }
377
+ has(seqno) {
378
+ return this.#packets.has(seqno);
379
+ }
380
+ clear() {
381
+ this.#packets.clear();
382
+ this.#order.length = 0;
383
+ }
384
+ };
385
+ /** Encoder for the 12-byte RTP fixed header of RAOP audio packets. */
386
+ const AudioPacketHeader = {
387
+ /**
388
+ * @param header - First byte with version, padding and extension flags, usually 0x80.
389
+ * @param payloadType - RTP payload type: 0xE0 for the first packet, 0x60 after that.
390
+ * @param timestamp - 32-bit RTP timestamp in audio frames.
391
+ * @param ssrc - Synchronization source identifier, the session ID.
392
+ */
393
+ encode(header, payloadType, seqno, timestamp, ssrc) {
394
+ const packet = Buffer.allocUnsafe(12);
395
+ packet.writeUInt8(header, 0);
396
+ packet.writeUInt8(payloadType, 1);
397
+ packet.writeUInt16BE(seqno, 2);
398
+ packet.writeUInt32BE(timestamp, 4);
399
+ packet.writeUInt32BE(ssrc, 8);
400
+ return packet;
401
+ } };
402
+ /**
403
+ * Encoder for the 20-byte RAOP sync packet. The control channel sends it
404
+ * periodically to sync the receiver's playback clock with our RTP timestamps.
405
+ */
406
+ const SyncPacket = {
407
+ /**
408
+ * @param header - First byte: 0x90 for the first sync, 0x80 after that.
409
+ * @param payloadType - Payload type, 0xD4 for sync.
410
+ * @param seqno - Sequence number, usually 0x0007.
411
+ * @param rtpTimestamp - RTP timestamp of the next packet to play, minus the latency.
412
+ * @param ntpSec - NTP seconds of the current wall-clock time.
413
+ * @param ntpFrac - NTP fraction of the current wall-clock time.
414
+ * @param rtpTimestampNow - RTP timestamp of the current head position.
415
+ */
416
+ encode(header, payloadType, seqno, rtpTimestamp, ntpSec, ntpFrac, rtpTimestampNow) {
417
+ const packet = Buffer.allocUnsafe(20);
418
+ packet.writeUInt8(header, 0);
419
+ packet.writeUInt8(payloadType, 1);
420
+ packet.writeUInt16BE(seqno, 2);
421
+ packet.writeUInt32BE(rtpTimestamp, 4);
422
+ packet.writeUInt32BE(ntpSec, 8);
423
+ packet.writeUInt32BE(ntpFrac, 12);
424
+ packet.writeUInt32BE(rtpTimestampNow, 16);
425
+ return packet;
426
+ } };
427
+ /**
428
+ * Decodes a retransmit request from the control channel.
429
+ *
430
+ * @throws Error when the packet is not an RTP retransmit request.
431
+ */
432
+ function decodeRetransmitRequest(data) {
433
+ if (data.length < 8 || data[0] >> 6 !== 2 || (data[1] & 127) !== 85) throw new Error("Invalid RTP retransmit request");
434
+ return {
435
+ lostSeqno: data.readUInt16BE(4),
436
+ lostPackets: data.readUInt16BE(6)
437
+ };
438
+ }
439
+
440
+ //#endregion
441
+ //#region src/raop/controlClient.ts
442
+ /**
443
+ * Converts an RTP timestamp to a 64-bit wall-clock NTP timestamp against the
444
+ * anchor taken at stream start, across the 32-bit wrap.
445
+ */
446
+ function ntpFromTs(rtpTimestamp, sampleRate, anchorRtp, anchorNtp) {
447
+ let elapsedSamples;
448
+ if (rtpTimestamp >= anchorRtp) elapsedSamples = rtpTimestamp - anchorRtp;
449
+ else elapsedSamples = 4294967296 - anchorRtp + rtpTimestamp;
450
+ const elapsedSeconds = Math.floor(elapsedSamples / sampleRate);
451
+ const elapsedFraction = elapsedSamples % sampleRate * 4294967295 / sampleRate;
452
+ return anchorNtp + (BigInt(elapsedSeconds) << 32n | BigInt(Math.floor(elapsedFraction)));
453
+ }
454
+ /**
455
+ * UDP control channel of a RAOP stream. It sends periodic sync packets so the
456
+ * receiver aligns its playback clock with our RTP timestamps, and answers
457
+ * retransmit requests from the packet backlog.
458
+ */
459
+ var ControlClient = class extends EventEmitter {
460
+ #appContext;
461
+ #transport;
462
+ #context;
463
+ #packetBacklog;
464
+ #syncTask;
465
+ #localPort;
466
+ /** RTP timestamp of the NTP anchor. */
467
+ #anchorRtp = 0;
468
+ /** NTP wall-clock timestamp of the anchor. */
469
+ #anchorNtp = 0n;
470
+ #remoteAddress;
471
+ constructor(appContext, context, packetBacklog) {
472
+ super();
473
+ this.#appContext = appContext;
474
+ this.#context = context;
475
+ this.#packetBacklog = packetBacklog;
476
+ }
477
+ /** Local UDP port of the control channel, or 0 before binding. */
478
+ get port() {
479
+ return this.#localPort ?? 0;
480
+ }
481
+ /**
482
+ * Binds the UDP socket and resolves once it listens. Rejects on a bind error.
483
+ *
484
+ * @param port - Local port to bind; 0 picks a free one.
485
+ */
486
+ async bind(localIp, port) {
487
+ return new Promise((resolve, reject) => {
488
+ this.#transport = createSocket("udp4");
489
+ this.#transport.on("error", (err) => {
490
+ this.#appContext.logger.error("[raop-control]", "Control connection error:", err);
491
+ reject(err);
492
+ });
493
+ this.#transport.on("message", (data, rinfo) => {
494
+ this.#onMessage(data, rinfo);
495
+ });
496
+ this.#transport.on("listening", () => {
497
+ const address = this.#transport.address();
498
+ this.#localPort = address.port;
499
+ resolve();
500
+ });
501
+ this.#transport.bind(port, localIp);
502
+ });
503
+ }
504
+ /** Stops the sync task and closes the UDP socket. */
505
+ close() {
506
+ this.stop();
507
+ if (this.#transport) {
508
+ this.#transport.close();
509
+ this.#transport = void 0;
510
+ }
511
+ }
512
+ /**
513
+ * Sends a sync packet to the receiver now and then every second.
514
+ *
515
+ * @param remoteAddr - IP address of the RAOP receiver.
516
+ * @throws When the sync task is already running.
517
+ */
518
+ start(remoteAddr) {
519
+ this.#remoteAddress = remoteAddr;
520
+ if (this.#syncTask) throw new Error("Already running");
521
+ this.#anchorRtp = this.#context.headTs;
522
+ this.#anchorNtp = NTP.now();
523
+ this.#startSyncTask(remoteAddr, this.#context.controlPort);
524
+ }
525
+ /** Stops the sync task and keeps the UDP socket open. */
526
+ stop() {
527
+ if (this.#syncTask) {
528
+ clearInterval(this.#syncTask);
529
+ this.#syncTask = void 0;
530
+ }
531
+ }
532
+ /**
533
+ * Starts the sync loop. The first packet has header byte 0x90 (marker bit
534
+ * set), the rest 0x80.
535
+ *
536
+ * @param port - Remote control port.
537
+ */
538
+ #startSyncTask(addr, port) {
539
+ let firstPacket = true;
540
+ const sendSync = () => {
541
+ if (!this.#transport) return;
542
+ const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate, this.#anchorRtp, this.#anchorNtp);
543
+ const [currentSec, currentFrac] = NTP.parts(currentTime);
544
+ const packet = SyncPacket.encode(firstPacket ? 144 : 128, 212, 7, this.#context.headTs - this.#context.latency, currentSec, currentFrac, this.#context.headTs);
545
+ firstPacket = false;
546
+ this.#transport.send(packet, port, addr);
547
+ };
548
+ sendSync();
549
+ this.#syncTask = setInterval(sendSync, 1e3);
550
+ }
551
+ /** Answers retransmit requests (type 0x55) and logs the other message types. */
552
+ #onMessage(data, rinfo) {
553
+ if (data.length < 8 || data[0] >> 6 !== 2 || rinfo.address !== this.#remoteAddress || rinfo.port !== this.#context.controlPort) return;
554
+ if ((data[1] & 127) === 85) this.#retransmitLostPackets(decodeRetransmitRequest(data), rinfo);
555
+ else this.#appContext.logger.debug("[raop-control]", "Received unhandled control data from", rinfo, data);
556
+ }
557
+ /**
558
+ * Resends each requested packet in a retransmit response (type 0xD6). A
559
+ * packet that left the backlog gets an empty response, so the receiver
560
+ * stops waiting for it.
561
+ */
562
+ #retransmitLostPackets(request, addr) {
563
+ for (let i = 0; i < Math.min(request.lostPackets, 512); i++) {
564
+ const seqno = request.lostSeqno + i & 65535;
565
+ if (this.#packetBacklog.has(seqno)) {
566
+ const packet = this.#packetBacklog.get(seqno);
567
+ if (packet.byteLength < 4) continue;
568
+ const originalSeqno = packet.subarray(2, 4);
569
+ const resp = Buffer.concat([
570
+ Buffer.from([128, 214]),
571
+ originalSeqno,
572
+ packet
573
+ ]);
574
+ if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
575
+ } else {
576
+ const seqBuf = Buffer.alloc(2);
577
+ seqBuf.writeUInt16BE(seqno);
578
+ const resp = Buffer.concat([
579
+ Buffer.from([128, 214]),
580
+ seqBuf,
581
+ Buffer.alloc(4)
582
+ ]);
583
+ if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
584
+ }
585
+ }
586
+ }
587
+ };
588
+
589
+ //#endregion
590
+ //#region src/raop/statistics.ts
591
+ /**
592
+ * Compares the frames sent against the frames due by the monotonic clock,
593
+ * to detect when sending falls behind real time.
594
+ */
595
+ var Statistics = class {
596
+ /** Sample rate in Hz. */
597
+ sampleRate;
598
+ /** Monotonic start time in nanoseconds. */
599
+ startTimeNs;
600
+ /** Start of the current reporting interval, in milliseconds. */
601
+ intervalTime;
602
+ /** Frames sent since streaming began. */
603
+ totalFrames = 0;
604
+ /** Frames sent in the current reporting interval. */
605
+ intervalFrames = 0;
606
+ /** @param sampleRate - Sample rate in Hz. */
607
+ constructor(sampleRate) {
608
+ this.sampleRate = sampleRate;
609
+ this.startTimeNs = process.hrtime.bigint();
610
+ this.intervalTime = performance.now();
611
+ }
612
+ /** Number of frames due by now since streaming started. */
613
+ get expectedFrameCount() {
614
+ const elapsedNs = Number(process.hrtime.bigint() - this.startTimeNs);
615
+ return Math.floor(elapsedNs / (1e9 / this.sampleRate));
616
+ }
617
+ /** Number of frames the sender lags behind real time; positive means send faster. */
618
+ get framesBehind() {
619
+ return this.expectedFrameCount - this.totalFrames;
620
+ }
621
+ /** True once the interval holds a second of frames, time to log and reset. */
622
+ get intervalCompleted() {
623
+ return this.intervalFrames >= this.sampleRate;
624
+ }
625
+ tick(sentFrames) {
626
+ this.totalFrames += sentFrames;
627
+ this.intervalFrames += sentFrames;
628
+ }
629
+ /**
630
+ * Ends the current reporting interval and resets its frame counter.
631
+ *
632
+ * @returns Elapsed seconds and frames sent in the interval that ended.
633
+ */
634
+ newInterval() {
635
+ const endTime = performance.now();
636
+ const diff = (endTime - this.intervalTime) / 1e3;
637
+ this.intervalTime = endTime;
638
+ const frames = this.intervalFrames;
639
+ this.intervalFrames = 0;
640
+ return [diff, frames];
641
+ }
642
+ };
643
+
644
+ //#endregion
645
+ //#region src/raop/utils.ts
646
+ /**
647
+ * Converts a volume percentage to dBFS for the RAOP `volume` SET_PARAMETER.
648
+ *
649
+ * @param volume - Volume percentage, from 0 (silent) to 100 (full).
650
+ * @returns Volume in dBFS, from -144 (mute) to 0 (full).
651
+ */
652
+ function pctToDbfs(volume) {
653
+ if (volume <= 0) return -144;
654
+ if (volume >= 100) return 0;
655
+ return 20 * Math.log10(volume / 100);
656
+ }
657
+ /**
658
+ * Reads the encryption types from the `et` field of the mDNS TXT record.
659
+ */
660
+ function getEncryptionTypes(properties) {
661
+ const field = properties.get("et");
662
+ if (!field) return 0;
663
+ let types = 0;
664
+ for (const entry of field.split(",")) {
665
+ const type = parseInt(entry.trim(), 10);
666
+ if (type === 0) types |= 1;
667
+ if (type === 1) types |= 2;
668
+ }
669
+ return types;
670
+ }
671
+ /**
672
+ * Reads the metadata types from the `md` field of the mDNS TXT record.
673
+ */
674
+ function getMetadataTypes(properties) {
675
+ const field = properties.get("md");
676
+ if (!field) return 0;
677
+ let types = 0;
678
+ for (const entry of field.split(",")) {
679
+ const type = parseInt(entry.trim(), 10);
680
+ if (type === 0) types |= 1;
681
+ if (type === 1) types |= 2;
682
+ if (type === 2) types |= 4;
683
+ }
684
+ return types;
685
+ }
686
+ /**
687
+ * Reads the audio format from the mDNS TXT record, with CD quality
688
+ * (44100 Hz, 2 channels, 16-bit) for missing fields.
689
+ *
690
+ * @returns `[sampleRate, channels, bytesPerChannel]`.
691
+ */
692
+ function getAudioProperties(properties) {
693
+ return [
694
+ parseInt(properties.get("sr") ?? "44100", 10),
695
+ parseInt(properties.get("ch") ?? "2", 10),
696
+ parseInt(properties.get("ss") ?? "16", 10) / 8
697
+ ];
698
+ }
699
+
700
+ //#endregion
701
+ //#region src/raop/streamClient.ts
702
+ /**
703
+ * Streams RAOP audio: negotiates encryption and metadata, sets up the control
704
+ * channel, sends PCM packets in real time, publishes metadata and artwork and
705
+ * tears down. When sending falls behind, it bursts extra packets to catch up.
706
+ */
707
+ var StreamClient = class extends EventEmitter {
708
+ /** The `/info` dictionary fetched during initialization. */
709
+ get info() {
710
+ return this.#info;
711
+ }
712
+ /** The playback info, with the fallback metadata when the caller gave none. */
713
+ get playbackInfo() {
714
+ return {
715
+ metadata: this.#isMetadataEmpty(this.#metadata) ? this.#missingMetadata : this.#metadata,
716
+ position: this.#streamContext.position
717
+ };
718
+ }
719
+ /** Only an AirPort with MFi-SAP encryption needs auth-setup. */
720
+ get #requiresAuthSetup() {
721
+ const modelName = this.#properties.get("am") ?? "";
722
+ return (this.#encryptionTypes & 2) !== 0 && modelName.startsWith("AirPort");
723
+ }
724
+ #context;
725
+ #rtsp;
726
+ #streamContext;
727
+ #settings;
728
+ #protocol;
729
+ #packetBacklog;
730
+ #timingServer;
731
+ /** Shown on the receiver when a stream comes without metadata. */
732
+ #missingMetadata;
733
+ #controlClient;
734
+ #encryptionTypes = 0;
735
+ #metadataTypes = 0;
736
+ #metadata = EMPTY_METADATA;
737
+ #info = {};
738
+ /** The receiver's mDNS TXT record. */
739
+ #properties = /* @__PURE__ */ new Map();
740
+ /** The streaming loop runs while this is true. */
741
+ #isPlaying = false;
742
+ /**
743
+ * @param missingMetadata - Shown on the receiver when a stream comes without metadata.
744
+ */
745
+ constructor(context, rtsp, streamContext, protocol, settings, timingServer, missingMetadata) {
746
+ super();
747
+ this.#context = context;
748
+ this.#rtsp = rtsp;
749
+ this.#streamContext = streamContext;
750
+ this.#protocol = protocol;
751
+ this.#settings = settings;
752
+ this.#packetBacklog = new PacketFifo(PACKET_BACKLOG_SIZE);
753
+ this.#timingServer = timingServer;
754
+ this.#missingMetadata = missingMetadata;
755
+ }
756
+ /** Closes the control channel and its UDP socket. */
757
+ close() {
758
+ this.#controlClient?.close();
759
+ this.#controlClient = void 0;
760
+ }
761
+ /**
762
+ * Reads the receiver's capabilities, binds the control channel, fetches
763
+ * `/info`, sends auth-setup when needed and runs the protocol SETUP.
764
+ *
765
+ * @param properties - The receiver's mDNS TXT record.
766
+ */
767
+ async initialize(properties) {
768
+ this.#properties = properties;
769
+ this.#encryptionTypes = getEncryptionTypes(properties);
770
+ this.#metadataTypes = getMetadataTypes(properties);
771
+ this.#context.logger.info(`Initializing RTSP with encryption=${this.#encryptionTypes}, metadata=${this.#metadataTypes}`);
772
+ const intersection = this.#encryptionTypes & SUPPORTED_ENCRYPTIONS;
773
+ if (!intersection || intersection === 0) this.#context.logger.debug("No supported encryption type, continuing anyway");
774
+ this.#updateOutputProperties(properties);
775
+ this.#controlClient = new ControlClient(this.#context, this.#streamContext, this.#packetBacklog);
776
+ await this.#controlClient.bind(this.#rtsp.connection.localIp, this.#settings.protocols.raop.controlPort);
777
+ this.#context.logger.debug(`Local ports: control=${this.#controlClient.port}, timing=${this.#timingServer.port}`);
778
+ const info = await this.#rtsp.info();
779
+ Object.assign(this.#info, info);
780
+ this.#context.logger.debug("Updated info parameters to:", this.#info);
781
+ if (this.#requiresAuthSetup) await this.#rtsp.authSetup();
782
+ await this.#protocol.setup(this.#timingServer.port, this.#controlClient.port);
783
+ }
784
+ /**
785
+ * Stops the streaming loop after the current packet. {@link sendAudio}
786
+ * returns once the teardown is done.
787
+ */
788
+ stop() {
789
+ this.#context.logger.debug("Stopping audio playback");
790
+ this.#isPlaying = false;
791
+ }
792
+ /**
793
+ * Sets the receiver volume with SET_PARAMETER.
794
+ *
795
+ * @param volume - Volume in dBFS.
796
+ */
797
+ async setVolume(volume) {
798
+ await this.#rtsp.setParameter("volume", String(volume));
799
+ this.#streamContext.volume = volume;
800
+ }
801
+ /**
802
+ * Streams PCM after setting up metadata, volume and RTSP RECORD.
803
+ * On completion or error, clears the backlog, tears down RTSP, closes UDP and emits `stopped`.
804
+ *
805
+ * @param volumeDb - Initial volume in dBFS, -144 to 0; the receiver keeps its own when omitted.
806
+ * @param onStreaming - Runs once the setup is done and the audio starts to flow.
807
+ */
808
+ async sendAudio(source, metadata = EMPTY_METADATA, volumeDb, onStreaming) {
809
+ if (!this.#controlClient) throw new Error("Not initialized");
810
+ this.#streamContext.reset();
811
+ let transport;
812
+ try {
813
+ transport = createSocket("udp4");
814
+ await new Promise((resolve, reject) => {
815
+ transport.once("error", reject);
816
+ transport.connect(this.#streamContext.serverPort, this.#rtsp.connection.remoteIp, () => {
817
+ transport.removeListener("error", reject);
818
+ resolve();
819
+ });
820
+ });
821
+ this.#controlClient.start(this.#rtsp.connection.remoteIp);
822
+ if ((this.#metadataTypes & 4) !== 0) {
823
+ const start = this.#streamContext.rtptime;
824
+ const now = this.#streamContext.rtptime;
825
+ const end = start + source.duration * this.#streamContext.sampleRate;
826
+ await this.#rtsp.setParameter("progress", `${start}/${now}/${end}`);
827
+ }
828
+ this.#metadata = metadata;
829
+ if ((this.#metadataTypes & 1) !== 0) {
830
+ this.#context.logger.debug("Playing with metadata:", this.playbackInfo.metadata);
831
+ await this.#rtsp.setMetadata(this.#streamContext.rtspSession, this.#streamContext.rtpseq, this.#streamContext.rtptime, this.playbackInfo.metadata);
832
+ }
833
+ if ((this.#metadataTypes & 2) !== 0 && metadata.artwork) {
834
+ this.#context.logger.debug(`Sending ${metadata.artwork.length} bytes artwork`);
835
+ await this.#rtsp.setArtwork(this.#streamContext.rtspSession, this.#streamContext.rtpseq, this.#streamContext.rtptime, metadata.artwork);
836
+ }
837
+ await this.#protocol.startFeedback();
838
+ this.emit("playing", this.playbackInfo);
839
+ await this.#rtsp.record({
840
+ "Range": "npt=0-",
841
+ "Session": this.#streamContext.rtspSession,
842
+ "RTP-Info": `seq=${this.#streamContext.rtpseq};rtptime=${this.#streamContext.rtptime}`
843
+ });
844
+ await this.#rtsp.flush({ headers: {
845
+ "Range": "npt=0-",
846
+ "Session": this.#streamContext.rtspSession,
847
+ "RTP-Info": `seq=${this.#streamContext.rtpseq};rtptime=${this.#streamContext.rtptime}`
848
+ } });
849
+ if (volumeDb !== void 0) await this.setVolume(volumeDb);
850
+ onStreaming?.();
851
+ await this.#streamData(source, transport);
852
+ } catch (err) {
853
+ this.#context.logger.error("An error occurred during streaming.", err);
854
+ throw new Error("An error occurred during streaming.", { cause: err });
855
+ } finally {
856
+ this.#packetBacklog.clear();
857
+ if (transport) {
858
+ await this.#rtsp.teardown(this.#streamContext.rtspSession).catch((err) => this.#context.logger.warn("TEARDOWN failed.", err));
859
+ transport.close();
860
+ }
861
+ this.#protocol.teardown();
862
+ this.close();
863
+ this.emit("stopped");
864
+ }
865
+ }
866
+ /**
867
+ * Sends the source as RTP packets, paced against the clock. When behind,
868
+ * it bursts up to {@link MAX_PACKETS_COMPENSATE} extra packets.
869
+ *
870
+ * @param transport - UDP socket connected to the receiver's audio port.
871
+ */
872
+ async #streamData(source, transport) {
873
+ const stats = new Statistics(this.#streamContext.sampleRate);
874
+ const initialTime = performance.now();
875
+ let prevSlowSeqno = null;
876
+ let numberSlowSeqno = 0;
877
+ this.#isPlaying = true;
878
+ while (this.#isPlaying) {
879
+ const currentSeqno = this.#streamContext.rtpseq - 1;
880
+ const numSent = await this.#sendPacket(source, stats.totalFrames === 0, transport);
881
+ if (numSent === 0) break;
882
+ stats.tick(numSent);
883
+ const framesBehind = stats.framesBehind;
884
+ if (framesBehind >= 352) {
885
+ const maxPackets = Math.min(Math.floor(framesBehind / 352), 3);
886
+ this.#context.logger.debug(`Compensating with ${maxPackets} packets (${framesBehind} frames behind)`);
887
+ const [sentFrames, hasMorePackets] = await this.#sendNumberOfPackets(source, transport, maxPackets);
888
+ stats.tick(sentFrames);
889
+ if (!hasMorePackets) break;
890
+ }
891
+ if (stats.intervalCompleted) {
892
+ const [intervalTime, intervalFrames] = stats.newInterval();
893
+ this.#context.logger.debug(`Sent ${intervalFrames} frames in ${intervalTime.toFixed(3)}s (current frames: ${stats.totalFrames}, expected: ${stats.expectedFrameCount})`);
894
+ }
895
+ const absTimeStream = stats.totalFrames / this.#streamContext.sampleRate;
896
+ const relToStart = (performance.now() - initialTime) / 1e3;
897
+ const diff = absTimeStream - relToStart;
898
+ if (diff > 0) {
899
+ numberSlowSeqno = 0;
900
+ await waitFor(diff * 1e3);
901
+ } else {
902
+ if (prevSlowSeqno === currentSeqno - 1) numberSlowSeqno++;
903
+ if (numberSlowSeqno >= 5) this.#context.logger.warn(`Too slow to keep up for seqno ${currentSeqno} (${absTimeStream.toFixed(3)} vs ${relToStart.toFixed(3)} => ${diff.toFixed(3)})`);
904
+ else this.#context.logger.debug(`Too slow to keep up for seqno ${currentSeqno} (${absTimeStream.toFixed(3)} vs ${relToStart.toFixed(3)} => ${diff.toFixed(3)})`);
905
+ prevSlowSeqno = currentSeqno;
906
+ }
907
+ }
908
+ const elapsedNs = Number(process.hrtime.bigint() - stats.startTimeNs);
909
+ this.#context.logger.debug(`Audio finished sending in ${(elapsedNs / 1e9).toFixed(3)}s`);
910
+ }
911
+ /**
912
+ * Reads one packet of frames and sends it as an RTP packet, zero-padded to
913
+ * the packet size. Once the source is exhausted, it sends silence until
914
+ * the latency is padded.
915
+ *
916
+ * @param firstPacket - Sends payload type 0xE0 with the marker bit.
917
+ * @param transport - UDP socket connected to the receiver's audio port.
918
+ * @returns Number of frames sent, or 0 when all padding has been sent.
919
+ */
920
+ async #sendPacket(source, firstPacket, transport) {
921
+ if (this.#streamContext.paddingSent >= this.#streamContext.latency) return 0;
922
+ let frames = await source.readFrames(352);
923
+ if (!frames) {
924
+ frames = Buffer.alloc(this.#streamContext.packetSize);
925
+ this.#streamContext.paddingSent += Math.floor(frames.length / this.#streamContext.frameSize);
926
+ } else if (frames.length !== this.#streamContext.packetSize) {
927
+ const padded = Buffer.alloc(this.#streamContext.packetSize);
928
+ frames.copy(padded);
929
+ frames = padded;
930
+ }
931
+ const header = AudioPacketHeader.encode(128, firstPacket ? 224 : 96, this.#streamContext.rtpseq, this.#streamContext.headTs, this.#rtsp.sessionId);
932
+ const [rtpseq, packet] = await this.#protocol.sendAudioPacket(transport, header, frames);
933
+ this.#packetBacklog.set(rtpseq, packet);
934
+ this.#streamContext.rtpseq = (this.#streamContext.rtpseq + 1) % 2 ** 16;
935
+ this.#streamContext.headTs = this.#streamContext.headTs + Math.floor(frames.length / this.#streamContext.frameSize) >>> 0;
936
+ return Math.floor(frames.length / this.#streamContext.frameSize);
937
+ }
938
+ /**
939
+ * Sends a burst of packets to catch up, and stops early when the source
940
+ * is exhausted.
941
+ *
942
+ * @returns The frames sent, and whether more packets are available.
943
+ */
944
+ async #sendNumberOfPackets(source, transport, count) {
945
+ let totalFrames = 0;
946
+ for (let i = 0; i < count; i++) {
947
+ const sent = await this.#sendPacket(source, false, transport);
948
+ totalFrames += sent;
949
+ if (sent === 0) return [totalFrames, false];
950
+ }
951
+ return [totalFrames, true];
952
+ }
953
+ /** True when the caller gave no metadata: all fields empty or zero. */
954
+ #isMetadataEmpty(metadata) {
955
+ return metadata.title === "" && metadata.artist === "" && metadata.album === "" && metadata.duration === 0;
956
+ }
957
+ /** Takes the audio format of the stream context from the mDNS TXT record. */
958
+ #updateOutputProperties(properties) {
959
+ const [sampleRate, channels, bytesPerChannel] = getAudioProperties(properties);
960
+ this.#streamContext.sampleRate = sampleRate;
961
+ this.#streamContext.channels = channels;
962
+ this.#streamContext.bytesPerChannel = bytesPerChannel;
963
+ this.#context.logger.debug(`Update play settings to ${sampleRate}/${channels}/${bytesPerChannel * 8}bit`);
964
+ }
965
+ };
966
+
967
+ //#endregion
968
+ //#region src/raop/session.ts
969
+ /** Where the RAOP service listens when the target does not say. */
970
+ const DEFAULT_PORT = 7e3;
971
+ /** Interval in milliseconds of the `/feedback` keepalive during a stream. */
972
+ const FEEDBACK_INTERVAL = 2e3;
973
+ /**
974
+ * Connects to a RAOP (AirPlay 1 audio) receiver and returns a ready session: the RTSP connection,
975
+ * the control channel, `GET /info`, `/auth-setup` for an AirPort, then ANNOUNCE and SETUP.
976
+ *
977
+ * @throws {ConnectionClosedError} When the signal aborts during the handshake.
978
+ */
979
+ async function connect(target, options) {
980
+ const signal = options.signal;
981
+ if (signal?.aborted) throw new ConnectionClosedError("RAOP setup was interrupted.");
982
+ const context = options.context ?? new Context(target.id ?? target.address);
983
+ const rtsp = new RaopRtspClient(context, target.address, target.port ?? DEFAULT_PORT);
984
+ const interrupt = () => rtsp.destroy();
985
+ let streamClient;
986
+ signal?.addEventListener("abort", interrupt, { once: true });
987
+ try {
988
+ await rtsp.connect();
989
+ const streamContext = createStreamContext();
990
+ streamContext.rtspSession = rtsp.rtspSessionId;
991
+ const settings = { protocols: { raop: {
992
+ controlPort: 0,
993
+ timingPort: 0
994
+ } } };
995
+ const missingMetadata = {
996
+ ...MISSING_METADATA,
997
+ ...options.metadata
998
+ };
999
+ streamClient = new StreamClient(context, rtsp, streamContext, new RaopStreamProtocol(rtsp, streamContext, options.password), settings, options.timing, missingMetadata);
1000
+ await streamClient.initialize(new Map(Object.entries(target.txt ?? {})));
1001
+ if (signal?.aborted || !rtsp.isConnected) throw new ConnectionClosedError("RAOP setup was interrupted.");
1002
+ return new RaopSession(target, context, rtsp, streamClient, streamContext, signal);
1003
+ } catch (err) {
1004
+ streamClient?.close();
1005
+ rtsp.destroy();
1006
+ throw signal?.aborted ? new ConnectionClosedError("RAOP setup was interrupted.") : err;
1007
+ } finally {
1008
+ signal?.removeEventListener("abort", interrupt);
1009
+ }
1010
+ }
1011
+ /**
1012
+ * One RAOP connection to a receiver. It lives once: it ends exactly once, with a reason, and then
1013
+ * its {@link signal} aborts and a running stream stops. One stream runs at a time.
1014
+ */
1015
+ var RaopSession = class extends EventEmitter {
1016
+ /** Why the session ended, or undefined while it is open. */
1017
+ get closeReason() {
1018
+ return this.#closeReason;
1019
+ }
1020
+ get context() {
1021
+ return this.#context;
1022
+ }
1023
+ /** The `/info` plist of the receiver; empty when it did not answer. */
1024
+ get info() {
1025
+ return this.#streamClient.info;
1026
+ }
1027
+ get isOpen() {
1028
+ return !this.signal.aborted;
1029
+ }
1030
+ /** Aborts with a {@link ConnectionClosedError} when the session ends. */
1031
+ get signal() {
1032
+ return this.#controller.signal;
1033
+ }
1034
+ get target() {
1035
+ return this.#target;
1036
+ }
1037
+ /** The volume last sent to the receiver, in dBFS. */
1038
+ get volumeDb() {
1039
+ return this.#streamContext.volume;
1040
+ }
1041
+ #context;
1042
+ #controller = new AbortController();
1043
+ #rtsp;
1044
+ #streamClient;
1045
+ #streamContext;
1046
+ #target;
1047
+ #closeReason;
1048
+ #playback;
1049
+ /** Use {@link connect}; a session starts from a finished handshake. */
1050
+ constructor(target, context, rtsp, streamClient, streamContext, signal) {
1051
+ super();
1052
+ this.#context = context;
1053
+ this.#rtsp = rtsp;
1054
+ this.#streamClient = streamClient;
1055
+ this.#streamContext = streamContext;
1056
+ this.#target = target;
1057
+ this.#rtsp.on("closed", (reason, error) => this.#end(reason, error));
1058
+ this.#streamClient.on("playing", (info) => this.emit("playing", info));
1059
+ this.#streamClient.on("stopped", () => this.emit("stopped"));
1060
+ signal?.addEventListener("abort", () => void this.close(), {
1061
+ once: true,
1062
+ signal: this.signal
1063
+ });
1064
+ }
1065
+ /** Stops a running stream and closes the connection, without TEARDOWN of an idle session. */
1066
+ async close() {
1067
+ if (this.signal.aborted) return;
1068
+ this.#playback?.stop();
1069
+ this.#streamClient.close();
1070
+ await this.#rtsp.disconnect();
1071
+ this.#end("local");
1072
+ }
1073
+ /** Sends a volume in dBFS, -144 (silent) to 0, with SET_PARAMETER. */
1074
+ async setVolumeDb(volumeDb) {
1075
+ this.#assertOpen();
1076
+ await this.#streamClient.setVolume(volumeDb);
1077
+ }
1078
+ /**
1079
+ * Streams PCM audio: metadata, RECORD, FLUSH and the volume, then the audio over RTP. Resolves
1080
+ * once the audio flows; {@link RaopPlayback.ended} settles when the source ran out and TEARDOWN
1081
+ * went out. The source is stopped in every case.
1082
+ *
1083
+ * @throws {PlaybackError} When a stream is already running.
1084
+ */
1085
+ async stream(source, options = {}) {
1086
+ this.#assertOpen();
1087
+ if (this.#playback) throw new PlaybackError("A RAOP stream is already active.");
1088
+ const sourceSession = new AudioSourceSession(source);
1089
+ const streamClient = this.#streamClient;
1090
+ let onStreaming;
1091
+ const streaming = new Promise((resolve) => onStreaming = resolve);
1092
+ const stop = () => {
1093
+ streamClient.stop();
1094
+ sourceSession.stop().catch((error) => this.#context.logger.error("[raop]", "Source cleanup failed", error));
1095
+ };
1096
+ const run = async () => {
1097
+ try {
1098
+ await sourceSession.start();
1099
+ await streamClient.sendAudio(sourceSession, options.metadata, options.volumeDb, onStreaming);
1100
+ } finally {
1101
+ await sourceSession.stop();
1102
+ if (this.#playback?.stop === stop) this.#playback = void 0;
1103
+ }
1104
+ };
1105
+ const playback = {
1106
+ ended: run(),
1107
+ stop
1108
+ };
1109
+ playback.ended.catch(() => void 0);
1110
+ this.#playback = playback;
1111
+ await Promise.race([streaming, playback.ended]);
1112
+ return playback;
1113
+ }
1114
+ #assertOpen() {
1115
+ if (this.signal.aborted) throw new ConnectionClosedError("This RAOP session has closed.");
1116
+ }
1117
+ /** The single exit: aborts the signal, stops a running stream and emits `closed`. */
1118
+ #end(reason, error) {
1119
+ if (this.#closeReason) return;
1120
+ this.#closeReason = reason;
1121
+ this.#context.logger.net("[raop]", `RAOP session closed (${reason}${error ? `: ${error.message}` : ""}).`);
1122
+ this.#playback?.stop();
1123
+ this.#streamClient.close();
1124
+ this.#controller.abort(new ConnectionClosedError(`RAOP session closed (${reason}).`));
1125
+ this.#rtsp.destroy();
1126
+ this.emit("closed", reason, error);
1127
+ }
1128
+ };
1129
+ /**
1130
+ * The RAOP {@link StreamProtocol}: ANNOUNCE and SETUP for the transport,
1131
+ * feedback keepalives and raw UDP audio packets.
1132
+ */
1133
+ var RaopStreamProtocol = class {
1134
+ #password;
1135
+ #rtsp;
1136
+ #streamContext;
1137
+ #feedbackInterval;
1138
+ /** @param password - Answers a digest challenge on ANNOUNCE; without one a 401 fails the setup. */
1139
+ constructor(rtsp, streamContext, password) {
1140
+ this.#password = password;
1141
+ this.#rtsp = rtsp;
1142
+ this.#streamContext = streamContext;
1143
+ }
1144
+ /**
1145
+ * Sends ANNOUNCE and SETUP and reads the receiver's server and control
1146
+ * ports from the Transport header of the response.
1147
+ *
1148
+ * @param timingPort - Local NTP timing server port.
1149
+ * @param controlPort - Local control channel port.
1150
+ */
1151
+ async setup(timingPort, controlPort) {
1152
+ await this.#rtsp.announce(this.#streamContext.bytesPerChannel, this.#streamContext.channels, this.#streamContext.sampleRate, this.#password);
1153
+ const transport = [
1154
+ "RTP/AVP/UDP",
1155
+ "unicast",
1156
+ "interleaved=0-1",
1157
+ "mode=record",
1158
+ `control_port=${controlPort}`,
1159
+ `timing_port=${timingPort}`
1160
+ ].join(";");
1161
+ const transportHeader = (await this.#rtsp.setup({ "Transport": transport })).headers.get("Transport");
1162
+ if (!transportHeader) return;
1163
+ const serverPortMatch = transportHeader.match(/server_port=(\d+)/);
1164
+ if (serverPortMatch) this.#streamContext.serverPort = parseInt(serverPortMatch[1], 10);
1165
+ const controlPortMatch = transportHeader.match(/control_port=(\d+)/);
1166
+ if (controlPortMatch) this.#streamContext.controlPort = parseInt(controlPortMatch[1], 10);
1167
+ }
1168
+ /** Sends `POST /feedback` every two seconds to keep the RAOP session alive during streaming. */
1169
+ async startFeedback() {
1170
+ this.#feedbackInterval = setInterval(async () => {
1171
+ try {
1172
+ await this.#rtsp.feedback(true);
1173
+ } catch (err) {
1174
+ this.#rtsp.context.logger.warn("[raop]", "Feedback failed", err);
1175
+ }
1176
+ }, FEEDBACK_INTERVAL);
1177
+ }
1178
+ /**
1179
+ * Sends the RTP header and audio payload as one UDP packet.
1180
+ *
1181
+ * @returns The sequence number and the full packet, for the backlog.
1182
+ */
1183
+ async sendAudioPacket(transport, header, audio) {
1184
+ const packet = Buffer.concat([header, audio]);
1185
+ const seqno = header.readUInt16BE(2);
1186
+ await new Promise((resolve, reject) => {
1187
+ transport.send(packet, (err) => err ? reject(err) : resolve());
1188
+ });
1189
+ return [seqno, packet];
1190
+ }
1191
+ /** Stops the feedback keepalive. */
1192
+ teardown() {
1193
+ if (!this.#feedbackInterval) return;
1194
+ clearInterval(this.#feedbackInterval);
1195
+ this.#feedbackInterval = void 0;
1196
+ }
1197
+ };
1198
+ /**
1199
+ * Creates a stream context with CD quality (44100 Hz, stereo, 16-bit) and a
1200
+ * random RTP sequence number and timestamp.
1201
+ */
1202
+ function createStreamContext() {
1203
+ const rtptime = Math.floor(Math.random() * 4294967295);
1204
+ return {
1205
+ sampleRate: AUDIO_SAMPLE_RATE,
1206
+ channels: 2,
1207
+ bytesPerChannel: 2,
1208
+ rtpseq: Math.floor(Math.random() * 65536),
1209
+ rtptime,
1210
+ headTs: rtptime,
1211
+ latency: Math.floor(AUDIO_SAMPLE_RATE * 2),
1212
+ serverPort: 0,
1213
+ controlPort: 0,
1214
+ rtspSession: "",
1215
+ volume: -20,
1216
+ position: 0,
1217
+ packetSize: 352 * 2 * 2,
1218
+ frameSize: 2 * 2,
1219
+ paddingSent: 0,
1220
+ reset() {
1221
+ this.rtpseq = Math.floor(Math.random() * 65536);
1222
+ this.rtptime = Math.floor(Math.random() * 4294967295);
1223
+ this.headTs = this.rtptime;
1224
+ this.paddingSent = 0;
1225
+ this.position = 0;
1226
+ }
1227
+ };
1228
+ }
1229
+
1230
+ //#endregion
1231
+ //#region src/raop/discovery.ts
1232
+ const RAOP_SERVICE = "_raop._tcp.local";
1233
+ /** Finds the RAOP (AirPlay 1 audio) receivers on the local network. */
1234
+ async function discover(options = {}) {
1235
+ return await new Discovery(RAOP_SERVICE).find(options.useCache ?? true);
1236
+ }
1237
+ /** The connect target for a discovered receiver. */
1238
+ function toTarget(result) {
1239
+ return {
1240
+ address: result.address,
1241
+ port: result.service.port,
1242
+ id: result.id,
1243
+ txt: result.txt
1244
+ };
1245
+ }
1246
+
1247
+ //#endregion
1248
+ export { EncryptionType, MetadataType, RAOP_SERVICE, RaopSession, connect, discover, getAudioProperties, getEncryptionTypes, getMetadataTypes, pctToDbfs, toTarget };