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