@basmilius/apple-airplay 0.9.18 → 0.9.19

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 CHANGED
@@ -42,6 +42,51 @@ declare class Verify {
42
42
  start(credentials: AccessoryCredentials): Promise<AccessoryKeys>;
43
43
  }
44
44
  //#endregion
45
+ //#region src/audioStream.d.ts
46
+ type AudioStreamContext = {
47
+ sampleRate: number;
48
+ channels: number;
49
+ bytesPerChannel: number;
50
+ frameSize: number;
51
+ packetSize: number;
52
+ rtpSeq: number;
53
+ rtpTime: number;
54
+ headTs: number;
55
+ latency: number;
56
+ paddingSent: number;
57
+ totalFrames: number;
58
+ };
59
+ declare class AudioStream {
60
+ #private;
61
+ constructor(protocol: Protocol);
62
+ setup(): Promise<{
63
+ dataPort: number;
64
+ controlPort: number;
65
+ }>;
66
+ /**
67
+ * Prepare the audio stream for sending. Connects the UDP data socket,
68
+ * initializes stream context, sends FLUSH, and starts RTCP sync.
69
+ */
70
+ prepare(remoteAddress: string): Promise<AudioStreamContext>;
71
+ /**
72
+ * Send pre-read frame data as an RTP packet. Used by AudioMultiplexer
73
+ * for multi-room streaming where frames are read once and sent to
74
+ * multiple streams.
75
+ */
76
+ sendFrameData(frames: Buffer, firstPacket: boolean): Promise<number>;
77
+ /**
78
+ * Send padding frames (silence) after audio ends and TEARDOWN.
79
+ */
80
+ finish(): Promise<void>;
81
+ /**
82
+ * Stream audio from a source. Convenience method that uses prepare(),
83
+ * sendFrameData() and finish() internally.
84
+ */
85
+ stream(source: AudioSource, remoteAddress: string): Promise<void>;
86
+ getPacket(seqno: number): Buffer | undefined;
87
+ close(): void;
88
+ }
89
+ //#endregion
45
90
  //#region ../../node_modules/.bun/@bufbuild+protobuf@2.11.0/node_modules/@bufbuild/protobuf/dist/esm/json-value.d.ts
46
91
  /**
47
92
  * Represents any possible JSON value:
@@ -10341,6 +10386,7 @@ type EventMap$1 = {
10341
10386
  readonly rawMessage: [ProtocolMessage];
10342
10387
  readonly deviceInfo: [DeviceInfoMessage];
10343
10388
  readonly deviceInfoUpdate: [DeviceInfoMessage];
10389
+ readonly keyboard: [KeyboardMessage];
10344
10390
  readonly originClientProperties: [OriginClientPropertiesMessage];
10345
10391
  readonly playerClientProperties: [PlayerClientPropertiesMessage];
10346
10392
  readonly removeClient: [RemoveClientMessage];
@@ -10380,6 +10426,16 @@ declare class EventStream extends BaseStream {
10380
10426
  }
10381
10427
  //#endregion
10382
10428
  //#region src/protocol.d.ts
10429
+ type PlaybackInfo = {
10430
+ duration?: number;
10431
+ position?: number;
10432
+ rate?: number;
10433
+ readyToPlay?: boolean;
10434
+ error?: {
10435
+ code: number;
10436
+ domain: string;
10437
+ };
10438
+ };
10383
10439
  declare class Protocol {
10384
10440
  #private;
10385
10441
  get context(): Context;
@@ -10400,24 +10456,39 @@ declare class Protocol {
10400
10456
  setupEventStream(sharedSecret: Buffer, pairingId: Buffer): Promise<void>;
10401
10457
  setupEventStreamForAudioStreaming(sharedSecret: Buffer, pairingId: Buffer): Promise<void>;
10402
10458
  playUrl(url: string, sharedSecret: Buffer, pairingId: Buffer, position?: number): Promise<void>;
10459
+ getPlaybackInfo(): Promise<PlaybackInfo | null>;
10460
+ waitForPlaybackEnd(): Promise<void>;
10461
+ stopPlayUrl(): void;
10403
10462
  setupAudioStream(source: AudioSource): Promise<void>;
10404
10463
  useTimingServer(timingServer: TimingServer): void;
10405
10464
  }
10406
10465
  //#endregion
10407
- //#region src/audioStream.d.ts
10408
- declare class AudioStream {
10466
+ //#region src/audioMultiplexer.d.ts
10467
+ /**
10468
+ * Streams audio from a single source to multiple AirPlay devices
10469
+ * simultaneously. Each device gets its own AudioStream with independent
10470
+ * encryption and RTP state, but they all receive the same audio data
10471
+ * with shared timing.
10472
+ */
10473
+ declare class AudioMultiplexer {
10409
10474
  #private;
10410
- constructor(protocol: Protocol);
10411
- setup(): Promise<{
10412
- dataPort: number;
10413
- controlPort: number;
10414
- }>;
10415
- stream(source: AudioSource, remoteAddress: string): Promise<void>;
10416
- getPacket(seqno: number): Buffer | undefined;
10417
- close(): void;
10475
+ constructor(context: Context);
10476
+ /**
10477
+ * Add a target device to stream to.
10478
+ */
10479
+ addTarget(protocol: Protocol): void;
10480
+ /**
10481
+ * Remove all targets and close their streams.
10482
+ */
10483
+ clear(): void;
10484
+ /**
10485
+ * Stream audio from a source to all targets simultaneously.
10486
+ * Sets up, prepares, and streams to all devices, then tears down.
10487
+ */
10488
+ stream(source: AudioSource): Promise<void>;
10418
10489
  }
10419
10490
  declare namespace dataStreamMessages_d_exports {
10420
- export { clientUpdatesConfig, configureConnection, deviceInfo, getExtension, getState, getVolume, getVolumeMuted, modifyOutputContext, notification, playbackQueueRequest, protocol, sendButtonEvent, sendCommand, sendCommandWithPlaybackPosition, sendCommandWithPlaybackRate, sendCommandWithRepeatMode, sendCommandWithShuffleMode, sendCommandWithSkipInterval, sendHIDEvent, sendVirtualTouchEvent, setConnectionState, setReadyState, setVolume, setVolumeMuted, wakeDevice };
10491
+ export { clientUpdatesConfig, configureConnection, deviceInfo, getExtension, getKeyboardSession, getState, getVolume, getVolumeMuted, modifyOutputContext, notification, playbackQueueRequest, protocol, sendButtonEvent, sendCommand, sendCommandWithPlaybackPosition, sendCommandWithPlaybackRate, sendCommandWithRepeatMode, sendCommandWithShuffleMode, sendCommandWithSkipInterval, sendHIDEvent, sendVirtualTouchEvent, setConnectionState, setReadyState, setVolume, setVolumeMuted, textInput, wakeDevice };
10421
10492
  }
10422
10493
  declare function protocol(type: ProtocolMessage_Type, errorCode?: ErrorCode_Enum): ProtocolMessage;
10423
10494
  declare function clientUpdatesConfig(artworkUpdates?: boolean, nowPlayingUpdates?: boolean, volumeUpdates?: boolean, keyboardUpdates?: boolean, outputDeviceUpdates?: boolean, systemEndpointUpdates?: boolean): [ProtocolMessage, DescExtension];
@@ -10429,6 +10500,8 @@ declare function getVolume(outputDeviceUID: string): [ProtocolMessage, DescExten
10429
10500
  declare function getVolumeMuted(outputDeviceUID: string): [ProtocolMessage, DescExtension];
10430
10501
  declare function notification(notification: string): [ProtocolMessage, DescExtension];
10431
10502
  declare function playbackQueueRequest(location: number, length: number, artworkWidth?: number, artworkHeight?: number): [ProtocolMessage, DescExtension];
10503
+ declare function getKeyboardSession(): [ProtocolMessage, DescExtension];
10504
+ declare function textInput(text: string, actionType: ActionType_Enum): [ProtocolMessage, DescExtension];
10432
10505
  declare function sendButtonEvent(usagePage: number, usage: number, buttonDown: boolean): [ProtocolMessage, DescExtension];
10433
10506
  declare function sendCommandWithSkipInterval(command: Command, skipInterval: number): [ProtocolMessage, DescExtension];
10434
10507
  declare function sendCommandWithPlaybackPosition(command: Command, playbackPosition: number): [ProtocolMessage, DescExtension];
@@ -10445,4 +10518,4 @@ declare function setVolumeMuted(outputDeviceUID: string, isMuted: boolean): [Pro
10445
10518
  declare function wakeDevice(): [ProtocolMessage, DescExtension];
10446
10519
  declare function getExtension<Desc extends DescExtension>(message: Extendee<Desc>, extension: Desc): ExtensionValueShape<Desc>;
10447
10520
  //#endregion
10448
- export { AudioStream, ControlStream, DataStream, dataStreamMessages_d_exports as DataStreamMessage, EventStream, Pairing, index_d_exports as Proto, Protocol, Verify };
10521
+ export { AudioMultiplexer, AudioStream, ControlStream, DataStream, dataStreamMessages_d_exports as DataStreamMessage, EventStream, Pairing, type PlaybackInfo, index_d_exports as Proto, Protocol, Verify };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createSocket } from "node:dgram";
4
- import { AccessoryPair, AccessoryVerify, Context, EncryptionAwareConnection, EncryptionState, HTTP_TIMEOUT, generateActiveRemoteId, generateDacpId, generateSessionId, getMacAddress, randomInt32, randomInt64, uint16ToBE, uuid } from "@basmilius/apple-common";
4
+ import { AccessoryPair, AccessoryVerify, ConnectionClosedError, Context, EncryptionAwareConnection, EncryptionError, EncryptionState, HTTP_TIMEOUT, PairingError, PlaybackError, SetupError, TimeoutError, generateActiveRemoteId, generateDacpId, generateSessionId, getMacAddress, randomInt32, randomInt64, uint16ToBE, uuid, waitFor } from "@basmilius/apple-common";
5
5
  import { NTP, Plist } from "@basmilius/apple-encoding";
6
6
  import { Chacha20, hkdf } from "@basmilius/apple-encryption";
7
7
  import { RtspClient, buildResponse, parseRequest } from "@basmilius/apple-rtsp";
@@ -14,6 +14,8 @@ const FRAMES_PER_PACKET = 352;
14
14
  const LATENCY_FRAMES = 11025;
15
15
  const PACKET_BACKLOG_SIZE = 1e3;
16
16
  const SYNC_INTERVAL = 1e3;
17
+ const MAX_PACKETS_COMPENSATE$1 = 3;
18
+ const SLOW_WARNING_THRESHOLD$1 = 5;
17
19
  const ntpFromTs = (timestamp, sampleRate) => {
18
20
  const seconds = Math.floor(timestamp / sampleRate);
19
21
  const fraction = timestamp % sampleRate * 4294967295 / sampleRate;
@@ -60,7 +62,7 @@ var AudioStream = class {
60
62
  latencyMax: 88200,
61
63
  latencyMin: 11025,
62
64
  shk: shkArrayBuffer,
63
- spf: FRAMES_PER_PACKET,
65
+ spf: 352,
64
66
  sr: SAMPLE_RATE,
65
67
  streamConnectionID,
66
68
  supportsDynamicStreamID: false,
@@ -71,7 +73,7 @@ var AudioStream = class {
71
73
  const response = await this.#protocol.controlStream.setup(`/${this.#protocol.controlStream.sessionId}`, Buffer.from(setupBody), { "Content-Type": "application/x-apple-binary-plist" });
72
74
  if (response.status !== 200) {
73
75
  const text = await response.text();
74
- throw new Error(`Failed to setup audio stream: ${response.status} - ${text}`);
76
+ throw new SetupError(`Failed to setup audio stream: ${response.status} - ${text}`);
75
77
  }
76
78
  const plist = Plist.parse(await response.arrayBuffer());
77
79
  this.#context.logger.debug("[audio]", "Setup stream response:", plist);
@@ -80,7 +82,7 @@ var AudioStream = class {
80
82
  this.#dataPort = streamInfo.dataPort & 65535;
81
83
  this.#remoteControlPort = streamInfo.controlPort & 65535;
82
84
  this.#context.logger.info("[audio]", `Audio stream setup: dataPort=${this.#dataPort}, controlPort=${this.#remoteControlPort}`);
83
- } else throw new Error("No stream info in SETUP response");
85
+ } else throw new SetupError("No stream info in SETUP response.");
84
86
  this.#context.logger.debug("[audio]", "Sending RECORD...");
85
87
  await this.#protocol.controlStream.record(`/${this.#protocol.controlStream.sessionId}`);
86
88
  this.#context.logger.debug("[audio]", "RECORD complete");
@@ -89,45 +91,86 @@ var AudioStream = class {
89
91
  controlPort: this.#remoteControlPort
90
92
  };
91
93
  }
92
- async stream(source, remoteAddress) {
93
- if (!this.#controlSocket || !this.#sharedKey || !this.#dataPort) throw new Error("Audio stream not setup");
94
+ /**
95
+ * Prepare the audio stream for sending. Connects the UDP data socket,
96
+ * initializes stream context, sends FLUSH, and starts RTCP sync.
97
+ */
98
+ async prepare(remoteAddress) {
99
+ if (!this.#controlSocket || !this.#sharedKey || !this.#dataPort) throw new SetupError("Audio stream not setup.");
94
100
  this.#dataSocket = createSocket("udp4");
95
- try {
96
- await new Promise((resolve, reject) => {
97
- this.#dataSocket.once("error", reject);
98
- this.#dataSocket.connect(this.#dataPort, remoteAddress, () => {
99
- this.#dataSocket.removeListener("error", reject);
100
- resolve();
101
- });
102
- });
103
- const frameSize = CHANNELS * BYTES_PER_CHANNEL;
104
- const ctx = {
105
- sampleRate: SAMPLE_RATE,
106
- channels: CHANNELS,
107
- bytesPerChannel: BYTES_PER_CHANNEL,
108
- frameSize,
109
- packetSize: FRAMES_PER_PACKET * frameSize,
110
- rtpSeq: randomInt32() & 65535,
111
- rtpTime: 0,
112
- headTs: 0,
113
- latency: LATENCY_FRAMES,
114
- paddingSent: 0,
115
- totalFrames: 0
116
- };
117
- this.#streamContext = ctx;
118
- this.#context.logger.debug("[audio]", "Sending FLUSH...");
119
- await this.#protocol.controlStream.flush(`/${this.#protocol.controlStream.sessionId}`, {
120
- "Range": "npt=0-",
121
- "RTP-Info": `seq=${ctx.rtpSeq};rtptime=${ctx.rtpTime}`
101
+ await new Promise((resolve, reject) => {
102
+ this.#dataSocket.once("error", reject);
103
+ this.#dataSocket.connect(this.#dataPort, remoteAddress, () => {
104
+ this.#dataSocket.removeListener("error", reject);
105
+ resolve();
122
106
  });
123
- this.#context.logger.debug("[audio]", "FLUSH complete");
107
+ });
108
+ const frameSize = CHANNELS * BYTES_PER_CHANNEL;
109
+ const ctx = {
110
+ sampleRate: SAMPLE_RATE,
111
+ channels: CHANNELS,
112
+ bytesPerChannel: BYTES_PER_CHANNEL,
113
+ frameSize,
114
+ packetSize: 352 * frameSize,
115
+ rtpSeq: randomInt32() & 65535,
116
+ rtpTime: 0,
117
+ headTs: 0,
118
+ latency: LATENCY_FRAMES,
119
+ paddingSent: 0,
120
+ totalFrames: 0
121
+ };
122
+ this.#streamContext = ctx;
123
+ this.#context.logger.debug("[audio]", "Sending FLUSH...");
124
+ await this.#protocol.controlStream.flush(`/${this.#protocol.controlStream.sessionId}`, {
125
+ "Range": "npt=0-",
126
+ "RTP-Info": `seq=${ctx.rtpSeq};rtptime=${ctx.rtpTime}`
127
+ });
128
+ this.#context.logger.debug("[audio]", "FLUSH complete");
129
+ this.#context.logger.debug("[audio]", `RTP start: seq=${ctx.rtpSeq}, time=${ctx.rtpTime}, ssrc=${this.#ssrc}`);
130
+ this.#packetBacklog.clear();
131
+ this.#startSync();
132
+ return ctx;
133
+ }
134
+ /**
135
+ * Send pre-read frame data as an RTP packet. Used by AudioMultiplexer
136
+ * for multi-room streaming where frames are read once and sent to
137
+ * multiple streams.
138
+ */
139
+ async sendFrameData(frames, firstPacket) {
140
+ if (!this.#streamContext) throw new SetupError("Audio stream not prepared.");
141
+ return this.#sendFrameBuffer(frames, firstPacket, this.#streamContext);
142
+ }
143
+ /**
144
+ * Send padding frames (silence) after audio ends and TEARDOWN.
145
+ */
146
+ async finish() {
147
+ if (!this.#streamContext) return;
148
+ const ctx = this.#streamContext;
149
+ while (ctx.paddingSent < ctx.latency) {
150
+ const silence = Buffer.alloc(ctx.packetSize, 0);
151
+ const sent = await this.#sendFrameBuffer(silence, false, ctx);
152
+ if (sent === 0) break;
153
+ ctx.paddingSent += sent;
154
+ const sleepTime = ctx.totalFrames / SAMPLE_RATE * 1e3 - performance.now();
155
+ if (sleepTime > 0) await this.#sleep(sleepTime);
156
+ }
157
+ this.#stopSync();
158
+ this.#context.logger.debug("[audio]", "Sending TEARDOWN...");
159
+ await this.#protocol.controlStream.teardown(`/${this.#protocol.controlStream.sessionId}`);
160
+ this.#context.logger.debug("[audio]", "TEARDOWN complete");
161
+ }
162
+ /**
163
+ * Stream audio from a source. Convenience method that uses prepare(),
164
+ * sendFrameData() and finish() internally.
165
+ */
166
+ async stream(source, remoteAddress) {
167
+ const ctx = await this.prepare(remoteAddress);
168
+ try {
124
169
  let firstPacket = true;
125
170
  let packetCount = 0;
171
+ let slowCount = 0;
126
172
  const startTime = performance.now();
127
173
  this.#context.logger.info("[audio]", "Starting audio stream...");
128
- this.#context.logger.debug("[audio]", `RTP start: seq=${ctx.rtpSeq}, time=${ctx.rtpTime}, ssrc=${this.#ssrc}`);
129
- this.#packetBacklog.clear();
130
- this.#startSync();
131
174
  while (true) {
132
175
  if (await this.#sendPacket(source, firstPacket, ctx) === 0) {
133
176
  this.#context.logger.debug("[audio]", `End of audio stream after ${packetCount} packets (padding complete)`);
@@ -137,7 +180,24 @@ var AudioStream = class {
137
180
  firstPacket = false;
138
181
  if (packetCount % 100 === 0) this.#context.logger.debug("[audio]", `Sent ${packetCount} packets, ${ctx.totalFrames} frames`);
139
182
  const sleepTime = ctx.totalFrames / SAMPLE_RATE * 1e3 - (performance.now() - startTime);
140
- if (sleepTime > 0) await this.#sleep(sleepTime);
183
+ if (sleepTime > 0) {
184
+ slowCount = 0;
185
+ await this.#sleep(sleepTime);
186
+ } else {
187
+ const framesBehind = Math.floor(-sleepTime / 1e3 * SAMPLE_RATE);
188
+ if (framesBehind >= 352) {
189
+ const extraPackets = Math.min(Math.floor(framesBehind / 352), MAX_PACKETS_COMPENSATE$1);
190
+ for (let i = 0; i < extraPackets; i++) {
191
+ if (await this.#sendPacket(source, false, ctx) === 0) break;
192
+ packetCount++;
193
+ }
194
+ }
195
+ slowCount++;
196
+ if (slowCount >= SLOW_WARNING_THRESHOLD$1) {
197
+ this.#context.logger.warn("[audio]", `Stream is behind schedule (${slowCount} consecutive slow packets, ${Math.abs(sleepTime).toFixed(1)}ms behind)`);
198
+ slowCount = 0;
199
+ }
200
+ }
141
201
  }
142
202
  this.#context.logger.info("[audio]", `Audio stream finished, sent ${packetCount} packets`);
143
203
  this.#stopSync();
@@ -154,7 +214,7 @@ var AudioStream = class {
154
214
  }
155
215
  async #sendPacket(source, firstPacket, ctx) {
156
216
  if (ctx.paddingSent >= ctx.latency) return 0;
157
- let frames = await source.readFrames(FRAMES_PER_PACKET);
217
+ let frames = await source.readFrames(352);
158
218
  if (!frames || frames.length === 0) {
159
219
  frames = Buffer.alloc(ctx.packetSize, 0);
160
220
  ctx.paddingSent += Math.floor(frames.length / ctx.frameSize);
@@ -163,6 +223,9 @@ var AudioStream = class {
163
223
  frames.copy(padded);
164
224
  frames = padded;
165
225
  }
226
+ return this.#sendFrameBuffer(frames, firstPacket, ctx);
227
+ }
228
+ #sendFrameBuffer(frames, firstPacket, ctx) {
166
229
  const rtpHeader = Buffer.allocUnsafe(12);
167
230
  rtpHeader.writeUInt8(128, 0);
168
231
  rtpHeader.writeUInt8(firstPacket ? 224 : 96, 1);
@@ -173,12 +236,11 @@ var AudioStream = class {
173
236
  const payload = this.#encryptAudio(frames, aad, ctx.rtpSeq);
174
237
  const packet = Buffer.concat([rtpHeader, payload]);
175
238
  this.#storePacket(ctx.rtpSeq, packet);
176
- await this.#send(packet);
177
239
  const framesSent = Math.floor(frames.length / ctx.frameSize);
178
240
  ctx.rtpSeq = ctx.rtpSeq + 1 & 65535;
179
241
  ctx.headTs = ctx.headTs + framesSent >>> 0;
180
242
  ctx.totalFrames += framesSent;
181
- return framesSent;
243
+ return this.#send(packet).then(() => framesSent);
182
244
  }
183
245
  #storePacket(seqno, packet) {
184
246
  this.#packetBacklog.set(seqno, packet);
@@ -191,7 +253,7 @@ var AudioStream = class {
191
253
  return this.#packetBacklog.get(seqno);
192
254
  }
193
255
  #encryptAudio(data, aad, seqNumber) {
194
- if (!this.#sharedKey) throw new Error("Encryption not setup");
256
+ if (!this.#sharedKey) throw new EncryptionError("Encryption not setup.");
195
257
  const nonceBytes = Buffer.alloc(12, 0);
196
258
  nonceBytes.writeUInt32LE(seqNumber, 4);
197
259
  const result = Chacha20.encrypt(this.#sharedKey, nonceBytes, aad, data);
@@ -227,7 +289,9 @@ var AudioStream = class {
227
289
  packet.writeUInt32BE(currentFrac, 12);
228
290
  packet.writeUInt32BE(ctx.headTs >>> 0, 16);
229
291
  firstPacket = false;
230
- this.#controlSocket.send(packet, this.#remoteControlPort, this.#protocol.discoveryResult.address);
292
+ this.#controlSocket.send(packet, this.#remoteControlPort, this.#protocol.discoveryResult.address, (err) => {
293
+ if (err) this.#protocol.context.logger.warn("[audio]", "Sync packet send failed", err);
294
+ });
231
295
  };
232
296
  sendSync();
233
297
  this.#syncInterval = setInterval(sendSync, SYNC_INTERVAL);
@@ -261,14 +325,135 @@ var AudioStream = class {
261
325
  }
262
326
  close() {
263
327
  this.#stopSync();
264
- this.#controlSocket?.close();
328
+ try {
329
+ this.#controlSocket?.removeAllListeners();
330
+ this.#controlSocket?.close();
331
+ } catch {}
265
332
  this.#controlSocket = void 0;
266
- this.#dataSocket?.close();
333
+ try {
334
+ this.#dataSocket?.close();
335
+ } catch {}
267
336
  this.#dataSocket = void 0;
268
337
  this.#packetBacklog.clear();
269
338
  }
270
339
  };
271
340
 
341
+ //#endregion
342
+ //#region src/audioMultiplexer.ts
343
+ const MAX_PACKETS_COMPENSATE = 3;
344
+ const SLOW_WARNING_THRESHOLD = 5;
345
+ /**
346
+ * Streams audio from a single source to multiple AirPlay devices
347
+ * simultaneously. Each device gets its own AudioStream with independent
348
+ * encryption and RTP state, but they all receive the same audio data
349
+ * with shared timing.
350
+ */
351
+ var AudioMultiplexer = class {
352
+ #context;
353
+ #targets = [];
354
+ constructor(context) {
355
+ this.#context = context;
356
+ }
357
+ /**
358
+ * Add a target device to stream to.
359
+ */
360
+ addTarget(protocol) {
361
+ const stream = new AudioStream(protocol);
362
+ this.#targets.push({
363
+ protocol,
364
+ stream
365
+ });
366
+ }
367
+ /**
368
+ * Remove all targets and close their streams.
369
+ */
370
+ clear() {
371
+ for (const target of this.#targets) target.stream.close();
372
+ this.#targets.length = 0;
373
+ }
374
+ /**
375
+ * Stream audio from a source to all targets simultaneously.
376
+ * Sets up, prepares, and streams to all devices, then tears down.
377
+ */
378
+ async stream(source) {
379
+ if (this.#targets.length === 0) return;
380
+ this.#context.logger.info("[multiplexer]", `Streaming to ${this.#targets.length} device(s)...`);
381
+ await Promise.all(this.#targets.map(async (target) => {
382
+ await target.stream.setup();
383
+ }));
384
+ await Promise.all(this.#targets.map(async (target) => {
385
+ await target.stream.prepare(target.protocol.discoveryResult.address);
386
+ }));
387
+ const packetSize = 352 * 4;
388
+ try {
389
+ let firstPacket = true;
390
+ let packetCount = 0;
391
+ let slowCount = 0;
392
+ let totalFrames = 0;
393
+ const startTime = performance.now();
394
+ this.#context.logger.info("[multiplexer]", "Starting multi-room audio stream...");
395
+ while (true) {
396
+ let frames = await source.readFrames(352);
397
+ if (!frames || frames.length === 0) {
398
+ this.#context.logger.debug("[multiplexer]", `End of source after ${packetCount} packets`);
399
+ break;
400
+ }
401
+ if (frames.length < packetSize) {
402
+ const padded = Buffer.alloc(packetSize, 0);
403
+ frames.copy(padded);
404
+ frames = padded;
405
+ }
406
+ await Promise.all(this.#targets.map(async (target) => {
407
+ await target.stream.sendFrameData(frames, firstPacket);
408
+ }));
409
+ totalFrames += 352;
410
+ packetCount++;
411
+ firstPacket = false;
412
+ if (packetCount % 100 === 0) this.#context.logger.debug("[multiplexer]", `Sent ${packetCount} packets to ${this.#targets.length} device(s)`);
413
+ const sleepTime = totalFrames / SAMPLE_RATE * 1e3 - (performance.now() - startTime);
414
+ if (sleepTime > 0) {
415
+ slowCount = 0;
416
+ await this.#sleep(sleepTime);
417
+ } else {
418
+ const framesBehind = Math.floor(-sleepTime / 1e3 * SAMPLE_RATE);
419
+ if (framesBehind >= 352) {
420
+ const extraPackets = Math.min(Math.floor(framesBehind / 352), MAX_PACKETS_COMPENSATE);
421
+ for (let i = 0; i < extraPackets; i++) {
422
+ let extraFrames = await source.readFrames(352);
423
+ if (!extraFrames || extraFrames.length === 0) break;
424
+ if (extraFrames.length < packetSize) {
425
+ const padded = Buffer.alloc(packetSize, 0);
426
+ extraFrames.copy(padded);
427
+ extraFrames = padded;
428
+ }
429
+ await Promise.all(this.#targets.map(async (target) => {
430
+ await target.stream.sendFrameData(extraFrames, false);
431
+ }));
432
+ totalFrames += 352;
433
+ packetCount++;
434
+ }
435
+ }
436
+ slowCount++;
437
+ if (slowCount >= SLOW_WARNING_THRESHOLD) {
438
+ this.#context.logger.warn("[multiplexer]", `Stream behind schedule (${slowCount} consecutive, ${Math.abs(sleepTime).toFixed(1)}ms behind)`);
439
+ slowCount = 0;
440
+ }
441
+ }
442
+ }
443
+ this.#context.logger.info("[multiplexer]", `Multi-room stream finished, sent ${packetCount} packets to ${this.#targets.length} device(s)`);
444
+ await Promise.all(this.#targets.map(async (target) => {
445
+ await target.stream.finish();
446
+ }));
447
+ } catch (err) {
448
+ for (const target of this.#targets) target.stream.close();
449
+ throw err;
450
+ }
451
+ }
452
+ #sleep(ms) {
453
+ return new Promise((resolve) => setTimeout(resolve, ms));
454
+ }
455
+ };
456
+
272
457
  //#endregion
273
458
  //#region ../../node_modules/.bun/@bufbuild+protobuf@2.11.0/node_modules/@bufbuild/protobuf/dist/esm/is-message.js
274
459
  /**
@@ -10672,7 +10857,7 @@ function readVariant(buf, offset = 0) {
10672
10857
  let result = 0;
10673
10858
  let shift = 0;
10674
10859
  let bytesRead = 0;
10675
- while (true) {
10860
+ while (offset + bytesRead < buf.length) {
10676
10861
  const byte = buf[offset + bytesRead++];
10677
10862
  result |= (byte & 127) << shift;
10678
10863
  if ((byte & 128) === 0) break;
@@ -10691,6 +10876,7 @@ function chacha20Decrypt(state, data) {
10691
10876
  if (offset + 2 > data.length) return false;
10692
10877
  const frameLength = data.readUInt16LE(offset);
10693
10878
  offset += 2;
10879
+ if (frameLength === 0 || frameLength > 65535) return false;
10694
10880
  const end = offset + frameLength + 16;
10695
10881
  if (end > data.length) return false;
10696
10882
  const ciphertext = data.subarray(offset, offset + frameLength);
@@ -10848,6 +11034,7 @@ var DataStream = class extends BaseStream {
10848
11034
  this.on("close", this.#onClose.bind(this));
10849
11035
  this.on("data", this.#onData.bind(this));
10850
11036
  this.on("error", this.#onError.bind(this));
11037
+ this.#handlers[ProtocolMessage_Type.KEYBOARD_MESSAGE] = [keyboardMessage, this.#onKeyboardMessage.bind(this)];
10851
11038
  this.#handlers[ProtocolMessage_Type.DEVICE_INFO_MESSAGE] = [deviceInfoMessage, this.#onDeviceInfoMessage.bind(this)];
10852
11039
  this.#handlers[ProtocolMessage_Type.DEVICE_INFO_UPDATE_MESSAGE] = [deviceInfoMessage, this.#onDeviceInfoUpdateMessage.bind(this)];
10853
11040
  this.#handlers[ProtocolMessage_Type.ORIGIN_CLIENT_PROPERTIES_MESSAGE] = [originClientPropertiesMessage, this.#onOriginClientPropertiesMessage.bind(this)];
@@ -10879,7 +11066,7 @@ var DataStream = class extends BaseStream {
10879
11066
  return new Promise((resolve, reject) => {
10880
11067
  const timer = setTimeout(() => {
10881
11068
  this.#outstanding.delete(identifier);
10882
- reject(/* @__PURE__ */ new Error(`Exchange timed out for ${identifier}`));
11069
+ reject(new TimeoutError(`Exchange timed out for ${identifier}`));
10883
11070
  }, timeout);
10884
11071
  this.#outstanding.set(identifier, {
10885
11072
  resolve,
@@ -10937,7 +11124,7 @@ var DataStream = class extends BaseStream {
10937
11124
  this.#buffer = Buffer.alloc(0);
10938
11125
  for (const [id, req] of this.#outstanding) {
10939
11126
  clearTimeout(req.timer);
10940
- req.reject(/* @__PURE__ */ new Error("Connection closed."));
11127
+ req.reject(new ConnectionClosedError());
10941
11128
  }
10942
11129
  this.#outstanding.clear();
10943
11130
  }
@@ -11010,6 +11197,10 @@ var DataStream = class extends BaseStream {
11010
11197
  handler(getExtension$1(message, extension));
11011
11198
  } else if (message.type !== ProtocolMessage_Type.UNKNOWN_MESSAGE) this.context.logger.warn("[data]", `Unknown message type ${message.type}.`);
11012
11199
  }
11200
+ #onKeyboardMessage(message) {
11201
+ this.context.logger.info("[data]", "Keyboard message", message);
11202
+ this.emit("keyboard", message);
11203
+ }
11013
11204
  #onDeviceInfoMessage(message) {
11014
11205
  this.context.logger.info("[data]", "Connected to device", message.name);
11015
11206
  this.emit("deviceInfo", message);
@@ -11217,7 +11408,7 @@ var Pairing = class {
11217
11408
  "Content-Type": "application/octet-stream",
11218
11409
  "X-Apple-HKP": this.#hkp.toString()
11219
11410
  });
11220
- if (response.status !== 200) throw new Error(`Cannot start pairing session. ${response.status} ${response.statusText} ${await response.text()}`);
11411
+ if (response.status !== 200) throw new PairingError(`Cannot start pairing session. ${response.status} ${response.statusText} ${await response.text()}`);
11221
11412
  }
11222
11413
  async #request(_, data) {
11223
11414
  const response = await this.#controlStream.post("/pair-setup", data, {
@@ -11272,6 +11463,10 @@ var Verify = class {
11272
11463
 
11273
11464
  //#endregion
11274
11465
  //#region src/protocol.ts
11466
+ const FEEDBACK_INTERVAL = 2e3;
11467
+ const PLAY_RETRIES = 3;
11468
+ const PLAYBACK_POLL_INTERVAL = 1e3;
11469
+ const PLAYBACK_IDLE_THRESHOLD = 5;
11275
11470
  var Protocol = class {
11276
11471
  get context() {
11277
11472
  return this.#context;
@@ -11309,6 +11504,7 @@ var Protocol = class {
11309
11504
  #audioStream;
11310
11505
  #dataStream;
11311
11506
  #eventStream;
11507
+ #playUrlFeedbackInterval;
11312
11508
  #timingServer;
11313
11509
  constructor(discoveryResult) {
11314
11510
  this.#context = new Context(discoveryResult.id);
@@ -11348,6 +11544,7 @@ var Protocol = class {
11348
11544
  } catch (err) {
11349
11545
  this.#context.logger.warn("[protocol]", "Error destroying control stream", err);
11350
11546
  }
11547
+ this.#stopPlayUrlFeedback();
11351
11548
  this.#audioStream = void 0;
11352
11549
  this.#dataStream = void 0;
11353
11550
  this.#eventStream = void 0;
@@ -11369,7 +11566,7 @@ var Protocol = class {
11369
11566
  }] });
11370
11567
  if (response.status !== 200) {
11371
11568
  this.context.logger.error("[protocol]", "Failed to setup data stream.", response.status, response.statusText, await response.text());
11372
- throw new Error("Failed to setup data stream.");
11569
+ throw new SetupError("Failed to setup data stream.");
11373
11570
  }
11374
11571
  const dataPort = Plist.parse(await response.arrayBuffer()).streams[0].dataPort & 65535;
11375
11572
  this.context.logger.net("[protocol]", `Connecting to data stream on port ${dataPort}...`);
@@ -11399,10 +11596,11 @@ var Protocol = class {
11399
11596
  const response = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, body);
11400
11597
  if (response.status !== 200) {
11401
11598
  this.context.logger.error("[protocol]", "Failed to setup event stream.", response.status, response.statusText, await response.text());
11402
- throw new Error("Failed to setup event stream.");
11599
+ throw new SetupError("Failed to setup event stream.");
11403
11600
  }
11404
11601
  const eventPort = Plist.parse(await response.arrayBuffer()).eventPort & 65535;
11405
11602
  this.context.logger.net("[protocol]", `Connecting to event stream on port ${eventPort}...`);
11603
+ this.#eventStream?.destroy();
11406
11604
  this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
11407
11605
  this.#eventStream.setup(sharedSecret);
11408
11606
  await this.#eventStream.connect();
@@ -11437,10 +11635,11 @@ var Protocol = class {
11437
11635
  const response = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, body);
11438
11636
  if (response.status !== 200) {
11439
11637
  this.context.logger.error("[protocol]", "Failed to setup event stream.", response.status, response.statusText, await response.text());
11440
- throw new Error("Failed to setup event stream.");
11638
+ throw new SetupError("Failed to setup event stream.");
11441
11639
  }
11442
11640
  const eventPort = Plist.parse(await response.arrayBuffer()).eventPort & 65535;
11443
11641
  this.context.logger.net("[protocol]", `Connecting to event stream on port ${eventPort}...`);
11642
+ this.#eventStream?.destroy();
11444
11643
  this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
11445
11644
  this.#eventStream.setup(sharedSecret);
11446
11645
  await this.#eventStream.connect();
@@ -11467,28 +11666,46 @@ var Protocol = class {
11467
11666
  setupBody.timingProtocol = "NTP";
11468
11667
  } else setupBody.timingProtocol = "None";
11469
11668
  const setupResponse = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, setupBody);
11470
- if (setupResponse.status !== 200) throw new Error(`Failed to setup for playback: ${setupResponse.status}`);
11669
+ if (setupResponse.status !== 200) throw new SetupError(`Failed to setup for playback: ${setupResponse.status}`);
11471
11670
  const eventPort = Plist.parse(await setupResponse.arrayBuffer()).eventPort & 65535;
11671
+ this.#eventStream?.destroy();
11472
11672
  this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
11473
11673
  this.#eventStream.setup(sharedSecret);
11474
11674
  await this.#eventStream.connect();
11675
+ this.#startPlayUrlFeedback();
11475
11676
  await this.#controlStream.record(`/${this.#controlStream.sessionId}`);
11476
- const response = await this.#controlStream.post("/play", {
11477
- "Content-Location": url,
11478
- "Start-Position-Seconds": position,
11479
- uuid: this.#sessionUUID.toUpperCase(),
11480
- streamType: 1,
11481
- mediaType: "file",
11482
- volume: 1,
11483
- rate: 1,
11484
- clientBundleID: "com.basmilius.apple-protocols",
11485
- clientProcName: "apple-protocols",
11486
- osBuildVersion: "18C66",
11487
- model: "iPhone16,2",
11488
- SenderMACAddress: getMacAddress().toUpperCase()
11489
- });
11490
- this.context.logger.info("[protocol]", `play_url response: ${response.status}`);
11491
- if (response.status !== 200) throw new Error(`Failed to play URL: ${response.status}`);
11677
+ let lastStatus = 0;
11678
+ for (let retry = 0; retry < PLAY_RETRIES; retry++) {
11679
+ lastStatus = (await this.#controlStream.post("/play", {
11680
+ "Content-Location": url,
11681
+ "Start-Position-Seconds": position,
11682
+ uuid: this.#sessionUUID.toUpperCase(),
11683
+ streamType: 1,
11684
+ mediaType: "file",
11685
+ volume: 1,
11686
+ rate: 1,
11687
+ clientBundleID: "com.basmilius.apple-protocols",
11688
+ clientProcName: "apple-protocols",
11689
+ osBuildVersion: "18C66",
11690
+ model: "iPhone16,2",
11691
+ SenderMACAddress: getMacAddress().toUpperCase()
11692
+ })).status;
11693
+ this.context.logger.info("[protocol]", `play_url response: ${lastStatus} (attempt ${retry + 1}/${PLAY_RETRIES})`);
11694
+ if (lastStatus === 200) break;
11695
+ if (lastStatus === 500) {
11696
+ this.context.logger.warn("[protocol]", "play_url returned 500, retrying...");
11697
+ await waitFor(1e3);
11698
+ continue;
11699
+ }
11700
+ if (lastStatus >= 400) {
11701
+ this.#stopPlayUrlFeedback();
11702
+ throw new PlaybackError(`Failed to play URL: ${lastStatus}`);
11703
+ }
11704
+ }
11705
+ if (lastStatus !== 200) {
11706
+ this.#stopPlayUrlFeedback();
11707
+ throw new PlaybackError(`Failed to play URL after ${PLAY_RETRIES} retries: ${lastStatus}`);
11708
+ }
11492
11709
  await this.#putProperty("isInterestedInDateRange", { value: true });
11493
11710
  await this.#putProperty("actionAtItemEnd", { value: 0 });
11494
11711
  await this.#controlStream.post("/rate?value=1.000000");
@@ -11505,6 +11722,63 @@ var Protocol = class {
11505
11722
  timescale: 0
11506
11723
  } });
11507
11724
  }
11725
+ async getPlaybackInfo() {
11726
+ try {
11727
+ const response = await this.#controlStream.get("/playback-info");
11728
+ if (!response.ok) return null;
11729
+ const body = await response.arrayBuffer();
11730
+ if (body.byteLength === 0) return {};
11731
+ return Plist.parse(body);
11732
+ } catch {
11733
+ return null;
11734
+ }
11735
+ }
11736
+ async waitForPlaybackEnd() {
11737
+ let playbackStarted = false;
11738
+ let idleCount = 0;
11739
+ while (true) {
11740
+ const info = await this.getPlaybackInfo();
11741
+ if (!info) {
11742
+ this.context.logger.debug("[protocol]", "Connection lost, assuming playback stopped.");
11743
+ break;
11744
+ }
11745
+ if (info.error) {
11746
+ this.#stopPlayUrlFeedback();
11747
+ throw new PlaybackError(`Playback error: ${info.error.code} (${info.error.domain})`);
11748
+ }
11749
+ if (info.duration !== void 0) {
11750
+ playbackStarted = true;
11751
+ idleCount = 0;
11752
+ } else if (playbackStarted) {
11753
+ idleCount++;
11754
+ if (idleCount >= PLAYBACK_IDLE_THRESHOLD) {
11755
+ this.context.logger.debug("[protocol]", "Playback ended.");
11756
+ break;
11757
+ }
11758
+ }
11759
+ await waitFor(PLAYBACK_POLL_INTERVAL);
11760
+ }
11761
+ this.#stopPlayUrlFeedback();
11762
+ }
11763
+ stopPlayUrl() {
11764
+ this.#stopPlayUrlFeedback();
11765
+ }
11766
+ #startPlayUrlFeedback() {
11767
+ this.#stopPlayUrlFeedback();
11768
+ this.#playUrlFeedbackInterval = setInterval(async () => {
11769
+ try {
11770
+ await this.feedback();
11771
+ } catch (err) {
11772
+ this.#context.logger.warn("[protocol]", "playUrl feedback error", err);
11773
+ }
11774
+ }, FEEDBACK_INTERVAL);
11775
+ }
11776
+ #stopPlayUrlFeedback() {
11777
+ if (this.#playUrlFeedbackInterval) {
11778
+ clearInterval(this.#playUrlFeedbackInterval);
11779
+ this.#playUrlFeedbackInterval = void 0;
11780
+ }
11781
+ }
11508
11782
  async #putProperty(property, body) {
11509
11783
  await this.#controlStream.put(`/setProperty?${property}`, body);
11510
11784
  }
@@ -11525,6 +11799,7 @@ var dataStreamMessages_exports = /* @__PURE__ */ __exportAll({
11525
11799
  configureConnection: () => configureConnection,
11526
11800
  deviceInfo: () => deviceInfo,
11527
11801
  getExtension: () => getExtension,
11802
+ getKeyboardSession: () => getKeyboardSession,
11528
11803
  getState: () => getState,
11529
11804
  getVolume: () => getVolume,
11530
11805
  getVolumeMuted: () => getVolumeMuted,
@@ -11545,6 +11820,7 @@ var dataStreamMessages_exports = /* @__PURE__ */ __exportAll({
11545
11820
  setReadyState: () => setReadyState,
11546
11821
  setVolume: () => setVolume,
11547
11822
  setVolumeMuted: () => setVolumeMuted,
11823
+ textInput: () => textInput,
11548
11824
  wakeDevice: () => wakeDevice
11549
11825
  });
11550
11826
  function protocol(type, errorCode = ErrorCode_Enum.NoError) {
@@ -11593,7 +11869,9 @@ function deviceInfo(pairingId) {
11593
11869
  supportsExtendedMotion: true,
11594
11870
  sharedQueueVersion: 2,
11595
11871
  deviceClass: DeviceClass_Enum.iPhone,
11596
- logicalDeviceCount: 1
11872
+ logicalDeviceCount: 1,
11873
+ clusterType: 0,
11874
+ isClusterAware: true
11597
11875
  });
11598
11876
  setExtension(protocolMessage, deviceInfoMessage, message);
11599
11877
  return [protocolMessage, deviceInfoMessage];
@@ -11604,7 +11882,10 @@ function modifyOutputContext(addingDevices = [], removingDevices = [], settingDe
11604
11882
  type: ModifyOutputContextRequestType_Enum.SharedAudioPresentation,
11605
11883
  addingDevices,
11606
11884
  removingDevices,
11607
- settingDevices
11885
+ settingDevices,
11886
+ clusterAwareAddingDevices: addingDevices,
11887
+ clusterAwareRemovingDevices: removingDevices,
11888
+ clusterAwareSettingDevices: settingDevices
11608
11889
  });
11609
11890
  setExtension(protocolMessage, modifyOutputContextRequestMessage, message);
11610
11891
  return [protocolMessage, modifyOutputContextRequestMessage];
@@ -11654,6 +11935,21 @@ function playbackQueueRequest(location, length, artworkWidth = 600, artworkHeigh
11654
11935
  setExtension(protocolMessage, playbackQueueRequestMessage, message);
11655
11936
  return [protocolMessage, playbackQueueRequestMessage];
11656
11937
  }
11938
+ function getKeyboardSession() {
11939
+ const protocolMessage = protocol(ProtocolMessage_Type.GET_KEYBOARD_SESSION_MESSAGE);
11940
+ setExtension(protocolMessage, getKeyboardSessionMessage, "");
11941
+ return [protocolMessage, getKeyboardSessionMessage];
11942
+ }
11943
+ function textInput(text, actionType) {
11944
+ const protocolMessage = protocol(ProtocolMessage_Type.TEXT_INPUT_MESSAGE);
11945
+ const message = create(TextInputMessageSchema, {
11946
+ timestamp: Date.now() / 1e3,
11947
+ text,
11948
+ actionType
11949
+ });
11950
+ setExtension(protocolMessage, textInputMessage, message);
11951
+ return [protocolMessage, textInputMessage];
11952
+ }
11657
11953
  function sendButtonEvent(usagePage, usage, buttonDown) {
11658
11954
  const protocolMessage = protocol(ProtocolMessage_Type.SEND_BUTTON_EVENT_MESSAGE);
11659
11955
  const message = create(SendButtonEventMessageSchema, {
@@ -11758,4 +12054,4 @@ function getExtension(message, extension) {
11758
12054
  }
11759
12055
 
11760
12056
  //#endregion
11761
- export { AudioStream, ControlStream, DataStream, dataStreamMessages_exports as DataStreamMessage, EventStream, Pairing, proto_exports as Proto, Protocol, Verify };
12057
+ export { AudioMultiplexer, AudioStream, ControlStream, DataStream, dataStreamMessages_exports as DataStreamMessage, EventStream, Pairing, proto_exports as Proto, Protocol, Verify };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@basmilius/apple-airplay",
3
3
  "description": "Implementation of Apple's AirPlay2 in Node.js.",
4
- "version": "0.9.18",
4
+ "version": "0.9.19",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -48,10 +48,10 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@basmilius/apple-common": "0.9.18",
52
- "@basmilius/apple-encoding": "0.9.18",
53
- "@basmilius/apple-encryption": "0.9.18",
54
- "@basmilius/apple-rtsp": "0.9.18"
51
+ "@basmilius/apple-common": "0.9.19",
52
+ "@basmilius/apple-encoding": "0.9.19",
53
+ "@basmilius/apple-encryption": "0.9.19",
54
+ "@basmilius/apple-rtsp": "0.9.19"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@bufbuild/buf": "^1.66.1",