lox-airplay-sender 0.1.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.
Files changed (75) hide show
  1. package/README.md +85 -0
  2. package/dist/core/ap2_test.d.ts +1 -0
  3. package/dist/core/ap2_test.js +8 -0
  4. package/dist/core/atv.d.ts +16 -0
  5. package/dist/core/atv.js +215 -0
  6. package/dist/core/atvAuthenticator.d.ts +30 -0
  7. package/dist/core/atvAuthenticator.js +134 -0
  8. package/dist/core/audioOut.d.ts +30 -0
  9. package/dist/core/audioOut.js +80 -0
  10. package/dist/core/deviceAirtunes.d.ts +72 -0
  11. package/dist/core/deviceAirtunes.js +501 -0
  12. package/dist/core/devices.d.ts +50 -0
  13. package/dist/core/devices.js +209 -0
  14. package/dist/core/index.d.ts +47 -0
  15. package/dist/core/index.js +97 -0
  16. package/dist/core/rtsp.d.ts +12 -0
  17. package/dist/core/rtsp.js +1590 -0
  18. package/dist/core/srp.d.ts +14 -0
  19. package/dist/core/srp.js +128 -0
  20. package/dist/core/udpServers.d.ts +26 -0
  21. package/dist/core/udpServers.js +149 -0
  22. package/dist/esm/core/ap2_test.js +8 -0
  23. package/dist/esm/core/atv.js +215 -0
  24. package/dist/esm/core/atvAuthenticator.js +134 -0
  25. package/dist/esm/core/audioOut.js +80 -0
  26. package/dist/esm/core/deviceAirtunes.js +501 -0
  27. package/dist/esm/core/devices.js +209 -0
  28. package/dist/esm/core/index.js +97 -0
  29. package/dist/esm/core/rtsp.js +1590 -0
  30. package/dist/esm/core/srp.js +128 -0
  31. package/dist/esm/core/udpServers.js +149 -0
  32. package/dist/esm/homekit/credentials.js +100 -0
  33. package/dist/esm/homekit/encryption.js +82 -0
  34. package/dist/esm/homekit/number.js +47 -0
  35. package/dist/esm/homekit/tlv.js +97 -0
  36. package/dist/esm/index.js +265 -0
  37. package/dist/esm/package.json +1 -0
  38. package/dist/esm/utils/alac.js +62 -0
  39. package/dist/esm/utils/alacEncoder.js +34 -0
  40. package/dist/esm/utils/circularBuffer.js +124 -0
  41. package/dist/esm/utils/config.js +28 -0
  42. package/dist/esm/utils/http.js +148 -0
  43. package/dist/esm/utils/ntp.js +27 -0
  44. package/dist/esm/utils/numUtil.js +17 -0
  45. package/dist/esm/utils/packetPool.js +52 -0
  46. package/dist/esm/utils/util.js +9 -0
  47. package/dist/homekit/credentials.d.ts +30 -0
  48. package/dist/homekit/credentials.js +100 -0
  49. package/dist/homekit/encryption.d.ts +12 -0
  50. package/dist/homekit/encryption.js +82 -0
  51. package/dist/homekit/number.d.ts +7 -0
  52. package/dist/homekit/number.js +47 -0
  53. package/dist/homekit/tlv.d.ts +25 -0
  54. package/dist/homekit/tlv.js +97 -0
  55. package/dist/index.d.ts +109 -0
  56. package/dist/index.js +265 -0
  57. package/dist/utils/alac.d.ts +9 -0
  58. package/dist/utils/alac.js +62 -0
  59. package/dist/utils/alacEncoder.d.ts +14 -0
  60. package/dist/utils/alacEncoder.js +34 -0
  61. package/dist/utils/circularBuffer.d.ts +31 -0
  62. package/dist/utils/circularBuffer.js +124 -0
  63. package/dist/utils/config.d.ts +25 -0
  64. package/dist/utils/config.js +28 -0
  65. package/dist/utils/http.d.ts +19 -0
  66. package/dist/utils/http.js +148 -0
  67. package/dist/utils/ntp.d.ts +7 -0
  68. package/dist/utils/ntp.js +27 -0
  69. package/dist/utils/numUtil.d.ts +5 -0
  70. package/dist/utils/numUtil.js +17 -0
  71. package/dist/utils/packetPool.d.ts +25 -0
  72. package/dist/utils/packetPool.js +52 -0
  73. package/dist/utils/util.d.ts +2 -0
  74. package/dist/utils/util.js +9 -0
  75. package/package.json +62 -0
package/dist/index.js ADDED
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LoxAirplaySender = void 0;
7
+ exports.start = start;
8
+ const node_events_1 = require("node:events");
9
+ // Airtunes implementation (node_airtunes2 port) in src/core.
10
+ const index_1 = __importDefault(require("./core/index"));
11
+ const config_1 = __importDefault(require("./utils/config"));
12
+ class LoxAirplaySender extends node_events_1.EventEmitter {
13
+ airtunes = null;
14
+ deviceKey = null;
15
+ started = false;
16
+ source = null;
17
+ log;
18
+ lastTrackKey = null;
19
+ lastCoverKey = null;
20
+ lastProgressKey = null;
21
+ lastCoverUrl = null;
22
+ coverFetch;
23
+ artworkTimer;
24
+ pendingArtwork;
25
+ lastTrackChangeAt = 0;
26
+ /**
27
+ * Create + start a sender for a single AirPlay device.
28
+ * Returns true when the pipeline initializes; safe to call multiple times to restart.
29
+ */
30
+ start(options, onEvent) {
31
+ if (this.started) {
32
+ this.stop();
33
+ }
34
+ this.log = options.log;
35
+ const inputCodec = options.inputCodec ?? 'pcm';
36
+ config_1.default.packet_size = inputCodec === 'alac' ? config_1.default.alac_packet_size : config_1.default.pcm_packet_size;
37
+ this.airtunes = new index_1.default({
38
+ packetSize: config_1.default.packet_size,
39
+ startTimeMs: options.startTimeMs,
40
+ });
41
+ this.airtunes.on('device', (key, status, desc) => {
42
+ onEvent?.({ event: 'device', message: status, detail: { key, desc } });
43
+ });
44
+ this.airtunes.on('buffer', (status) => {
45
+ onEvent?.({ event: 'buffer', message: status });
46
+ });
47
+ this.airtunes.on('error', (err) => {
48
+ onEvent?.({ event: 'error', message: err instanceof Error ? err.message : String(err) });
49
+ });
50
+ const dev = this.airtunes.add(options.host, {
51
+ port: options.port,
52
+ name: options.name,
53
+ password: options.password ?? null,
54
+ volume: options.volume ?? 50,
55
+ mode: options.mode ?? (options.airplay2 ? 2 : 0),
56
+ txt: options.txt ?? [],
57
+ forceAlac: options.forceAlac ?? true,
58
+ alacEncoding: options.alacEncoding ?? true,
59
+ inputCodec,
60
+ airplay2: options.airplay2 ?? false,
61
+ debug: options.debug ?? false,
62
+ log: options.log,
63
+ });
64
+ this.deviceKey = dev?.key ?? `${options.host}:${options.port ?? 5000}`;
65
+ this.started = true;
66
+ return true;
67
+ }
68
+ /**
69
+ * Push raw PCM or ALAC frames into the stream.
70
+ */
71
+ sendPcm(chunk) {
72
+ if (!this.airtunes || !this.started)
73
+ return;
74
+ this.airtunes.write(chunk);
75
+ }
76
+ /**
77
+ * Pipe a readable stream into the sender; auto-stops on end/error.
78
+ */
79
+ pipeStream(stream) {
80
+ if (!this.airtunes || !this.started)
81
+ return;
82
+ this.source = stream;
83
+ stream.on('data', (chunk) => this.sendPcm(chunk));
84
+ stream.on('end', () => this.stop());
85
+ stream.on('error', () => this.stop());
86
+ }
87
+ /** Adjust receiver volume (0–100). */
88
+ setVolume(volume) {
89
+ if (!this.airtunes || !this.deviceKey)
90
+ return;
91
+ this.airtunes.setVolume(this.deviceKey, volume);
92
+ }
93
+ /** Update track metadata immediately without artwork/progress. */
94
+ setTrackInfo(title, artist, album) {
95
+ if (!this.airtunes || !this.deviceKey)
96
+ return;
97
+ this.airtunes.setTrackInfo(this.deviceKey, title, artist, album);
98
+ }
99
+ /** Send cover art immediately. */
100
+ setArtwork(art, contentType) {
101
+ if (!this.airtunes || !this.deviceKey)
102
+ return;
103
+ this.airtunes.setArtwork(this.deviceKey, art, contentType);
104
+ }
105
+ /** Send playback progress in seconds (elapsed, duration). */
106
+ setProgress(progress, duration) {
107
+ if (!this.airtunes || !this.deviceKey)
108
+ return;
109
+ this.airtunes.setProgress(this.deviceKey, progress, duration);
110
+ }
111
+ /**
112
+ * Convenience to send track info, cover (buffer or URL), and progress.
113
+ * Deduplicates payloads and staggers artwork on track changes.
114
+ */
115
+ async setMetadata(payload) {
116
+ if (!this.airtunes || !this.deviceKey)
117
+ return;
118
+ const title = payload.title ?? '';
119
+ const artist = payload.artist ?? '';
120
+ const album = payload.album ?? '';
121
+ const trackKey = `${title}::${artist}::${album}`;
122
+ const trackChanged = Boolean(title && trackKey !== this.lastTrackKey);
123
+ if (trackChanged) {
124
+ this.setTrackInfo(title, artist, album);
125
+ this.lastTrackKey = trackKey;
126
+ this.lastCoverKey = null;
127
+ this.lastCoverUrl = null;
128
+ this.lastTrackChangeAt = Date.now();
129
+ }
130
+ let coverPayload = payload.cover;
131
+ const coverUrl = payload.coverUrl;
132
+ if (!coverPayload?.data && coverUrl) {
133
+ if (coverUrl !== this.lastCoverUrl && !this.coverFetch) {
134
+ this.lastCoverUrl = coverUrl;
135
+ this.lastCoverKey = null;
136
+ this.coverFetch = this.fetchCover(coverUrl).finally(() => {
137
+ this.coverFetch = undefined;
138
+ });
139
+ }
140
+ if (this.coverFetch) {
141
+ coverPayload = (await this.coverFetch) ?? undefined;
142
+ }
143
+ }
144
+ if (coverPayload?.data) {
145
+ const coverKey = coverUrl
146
+ ? `${coverUrl}:${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`
147
+ : `${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`;
148
+ if (coverKey !== this.lastCoverKey) {
149
+ if (trackChanged) {
150
+ this.queueArtwork(coverPayload, coverKey, 200);
151
+ }
152
+ else {
153
+ this.sendArtworkNow(coverPayload, coverKey);
154
+ }
155
+ }
156
+ }
157
+ const durationInput = typeof payload.durationMs === 'number' && payload.durationMs > 0 ? payload.durationMs : null;
158
+ const elapsedInput = typeof payload.elapsedMs === 'number' && payload.elapsedMs >= 0 ? payload.elapsedMs : null;
159
+ const durationSec = durationInput !== null ? Math.floor(durationInput > 1000 ? durationInput / 1000 : durationInput) : null;
160
+ const elapsedSecRaw = elapsedInput !== null ? Math.floor(elapsedInput > 1000 ? elapsedInput / 1000 : elapsedInput) : null;
161
+ if (durationSec !== null && elapsedSecRaw !== null && durationSec > 0) {
162
+ const elapsedSec = Math.min(Math.max(0, elapsedSecRaw), durationSec);
163
+ const progressKey = `${elapsedSec}/${durationSec}`;
164
+ if (progressKey !== this.lastProgressKey) {
165
+ this.setProgress(elapsedSec, durationSec);
166
+ this.lastProgressKey = progressKey;
167
+ }
168
+ }
169
+ }
170
+ /** Provide a passcode when a receiver requests it. */
171
+ setPasscode(passcode) {
172
+ if (!this.airtunes || !this.deviceKey)
173
+ return;
174
+ this.airtunes.setPasscode(this.deviceKey, passcode);
175
+ }
176
+ /**
177
+ * Stop streaming and tear down state/sockets. Safe to call multiple times.
178
+ */
179
+ stop() {
180
+ if (this.source) {
181
+ try {
182
+ this.source.destroy();
183
+ }
184
+ catch {
185
+ // ignore
186
+ }
187
+ this.source = null;
188
+ }
189
+ this.lastTrackKey = null;
190
+ this.lastCoverKey = null;
191
+ this.lastProgressKey = null;
192
+ this.lastCoverUrl = null;
193
+ this.lastTrackChangeAt = 0;
194
+ this.pendingArtwork = undefined;
195
+ if (this.artworkTimer) {
196
+ clearTimeout(this.artworkTimer);
197
+ this.artworkTimer = undefined;
198
+ }
199
+ if (this.airtunes) {
200
+ if (this.deviceKey) {
201
+ this.airtunes.stop(this.deviceKey);
202
+ }
203
+ this.airtunes.stopAll?.(() => undefined);
204
+ this.airtunes.end?.();
205
+ }
206
+ this.airtunes = null;
207
+ this.deviceKey = null;
208
+ this.started = false;
209
+ }
210
+ async fetchCover(url) {
211
+ const controller = new AbortController();
212
+ const timeout = setTimeout(() => controller.abort(), 4000);
213
+ try {
214
+ const response = await fetch(url, { signal: controller.signal });
215
+ if (!response.ok) {
216
+ this.log?.('warn', 'airplay cover fetch failed', { status: response.status, url });
217
+ return null;
218
+ }
219
+ const mime = response.headers.get('content-type') || undefined;
220
+ const buffer = Buffer.from(await response.arrayBuffer());
221
+ if (!buffer.length) {
222
+ this.log?.('warn', 'airplay cover fetch empty', { url });
223
+ return null;
224
+ }
225
+ return { data: buffer, mime };
226
+ }
227
+ catch {
228
+ this.log?.('warn', 'airplay cover fetch error', { url });
229
+ return null;
230
+ }
231
+ finally {
232
+ clearTimeout(timeout);
233
+ }
234
+ }
235
+ sendArtworkNow(payload, coverKey) {
236
+ this.setArtwork(payload.data, payload.mime);
237
+ this.lastCoverKey = coverKey;
238
+ }
239
+ queueArtwork(payload, coverKey, delayMs) {
240
+ this.pendingArtwork = { ...payload, key: coverKey };
241
+ if (this.artworkTimer) {
242
+ clearTimeout(this.artworkTimer);
243
+ }
244
+ this.artworkTimer = setTimeout(() => {
245
+ this.artworkTimer = undefined;
246
+ const pending = this.pendingArtwork;
247
+ this.pendingArtwork = undefined;
248
+ if (!pending)
249
+ return;
250
+ if (pending.key === this.lastCoverKey)
251
+ return;
252
+ this.sendArtworkNow({ data: pending.data, mime: pending.mime }, pending.key);
253
+ }, delayMs);
254
+ }
255
+ }
256
+ exports.LoxAirplaySender = LoxAirplaySender;
257
+ /**
258
+ * Convenience helper to construct + start a sender in one call.
259
+ */
260
+ function start(options, onEvent) {
261
+ const sender = new LoxAirplaySender();
262
+ sender.start(options, onEvent);
263
+ return sender;
264
+ }
265
+ exports.default = LoxAirplaySender;
@@ -0,0 +1,9 @@
1
+ /** PCM packet size (bytes) expected by the encoder. */
2
+ export declare const PCM_PACKET_SIZE: number;
3
+ /** Output ALAC packet size (bytes). */
4
+ export declare const ALAC_PACKET_SIZE: number;
5
+ /**
6
+ * Encode one PCM frame (16-bit LE stereo, 44.1kHz) into ALAC.
7
+ * Input must be exactly `PCM_PACKET_SIZE` bytes.
8
+ */
9
+ export declare function encodePcmToAlac(pcmData: Buffer): Buffer;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ALAC_PACKET_SIZE = exports.PCM_PACKET_SIZE = void 0;
7
+ exports.encodePcmToAlac = encodePcmToAlac;
8
+ const config_1 = __importDefault(require("./config"));
9
+ /** PCM packet size (bytes) expected by the encoder. */
10
+ exports.PCM_PACKET_SIZE = config_1.default.pcm_packet_size;
11
+ /** Output ALAC packet size (bytes). */
12
+ exports.ALAC_PACKET_SIZE = config_1.default.alac_packet_size;
13
+ /**
14
+ * Encode one PCM frame (16-bit LE stereo, 44.1kHz) into ALAC.
15
+ * Input must be exactly `PCM_PACKET_SIZE` bytes.
16
+ */
17
+ function encodePcmToAlac(pcmData) {
18
+ let alacData = Buffer.alloc(exports.ALAC_PACKET_SIZE);
19
+ const bsize = 352;
20
+ const frames = 352;
21
+ const p = new Uint8Array(exports.ALAC_PACKET_SIZE);
22
+ const input = new Uint32Array(pcmData.length / 4);
23
+ let j = 0;
24
+ for (let i = 0; i < pcmData.length; i += 4) {
25
+ let res = pcmData[i];
26
+ res |= pcmData[i + 1] << 8;
27
+ res |= pcmData[i + 2] << 16;
28
+ res |= pcmData[i + 3] << 24;
29
+ input[j++] = res;
30
+ }
31
+ let pindex = 0;
32
+ let iindex = 0;
33
+ p[pindex++] = 1 << 5;
34
+ p[pindex++] = 0;
35
+ p[pindex++] = (1 << 4) | (1 << 1) | ((bsize & 0x80000000) >>> 31);
36
+ p[pindex++] = ((bsize & 0x7f800000) << 1) >>> 24;
37
+ p[pindex++] = ((bsize & 0x007f8000) << 1) >>> 16;
38
+ p[pindex++] = ((bsize & 0x00007f80) << 1) >>> 8;
39
+ p[pindex] = (bsize & 0x0000007f) << 1;
40
+ p[pindex++] |= (input[iindex] & 0x00008000) >>> 15;
41
+ let count = frames - 1;
42
+ while (count--) {
43
+ const i = input[iindex++];
44
+ p[pindex++] = (i & 0x00007f80) >>> 7;
45
+ p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31);
46
+ p[pindex++] = (i & 0x7f800000) >>> 23;
47
+ p[pindex++] = ((i & 0x007f0000) >>> 15) | ((input[iindex] & 0x00008000) >> 15);
48
+ }
49
+ const i = input[iindex];
50
+ p[pindex++] = (i & 0x00007f80) >>> 7;
51
+ p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31);
52
+ p[pindex++] = (i & 0x7f800000) >>> 23;
53
+ p[pindex++] = (i & 0x007f0000) >>> 15;
54
+ count = (bsize - frames) * 4;
55
+ while (count--)
56
+ p[pindex++] = 0;
57
+ p[pindex - 1] |= 1;
58
+ p[pindex++] = (7 >>> 1) << 6;
59
+ const alacSize = pindex;
60
+ alacData = Buffer.from(p.buffer);
61
+ return alacData.slice(0, alacSize);
62
+ }
@@ -0,0 +1,14 @@
1
+ import { Transform, type TransformCallback } from 'node:stream';
2
+ /**
3
+ * Transforms PCM (16-bit LE, stereo, 44.1kHz) into fixed-size ALAC frames.
4
+ * Emits ALAC packets sized per `ALAC_PACKET_SIZE`.
5
+ */
6
+ export declare class AlacEncoderStream extends Transform {
7
+ private buffer;
8
+ /** Create a streaming ALAC encoder for PCM input. */
9
+ constructor();
10
+ /**
11
+ * Buffer PCM until a full frame is available, then emit ALAC.
12
+ */
13
+ _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
14
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AlacEncoderStream = void 0;
4
+ const node_stream_1 = require("node:stream");
5
+ const alac_1 = require("./alac");
6
+ /**
7
+ * Transforms PCM (16-bit LE, stereo, 44.1kHz) into fixed-size ALAC frames.
8
+ * Emits ALAC packets sized per `ALAC_PACKET_SIZE`.
9
+ */
10
+ class AlacEncoderStream extends node_stream_1.Transform {
11
+ buffer = Buffer.alloc(0);
12
+ /** Create a streaming ALAC encoder for PCM input. */
13
+ constructor() {
14
+ super();
15
+ }
16
+ /**
17
+ * Buffer PCM until a full frame is available, then emit ALAC.
18
+ */
19
+ _transform(chunk, _encoding, callback) {
20
+ if (!chunk?.length) {
21
+ callback();
22
+ return;
23
+ }
24
+ this.buffer = (this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk);
25
+ while (this.buffer.length >= alac_1.PCM_PACKET_SIZE) {
26
+ const frame = this.buffer.subarray(0, alac_1.PCM_PACKET_SIZE);
27
+ this.buffer = this.buffer.subarray(alac_1.PCM_PACKET_SIZE);
28
+ const alac = (0, alac_1.encodePcmToAlac)(frame);
29
+ this.push(alac);
30
+ }
31
+ callback();
32
+ }
33
+ }
34
+ exports.AlacEncoderStream = AlacEncoderStream;
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Packet } from './packetPool';
3
+ export type BufferStatus = 'buffering' | 'playing' | 'drain' | 'end';
4
+ /**
5
+ * Fixed-size circular buffer that smooths incoming PCM/ALAC chunks into fixed packet sizes.
6
+ * Emits status changes for buffering/playing/drain/end to drive UI + sync.
7
+ */
8
+ export default class CircularBuffer extends EventEmitter {
9
+ private readonly packetPool;
10
+ private readonly maxSize;
11
+ private readonly packetSize;
12
+ writable: boolean;
13
+ muted: boolean;
14
+ private buffers;
15
+ private currentSize;
16
+ private status;
17
+ constructor(packetsInBuffer: number, packetSize: number);
18
+ /**
19
+ * Write a PCM/ALAC chunk into the buffer.
20
+ * Returns false when the buffer is full so upstream can throttle.
21
+ */
22
+ write(chunk: Buffer): boolean;
23
+ /**
24
+ * Read the next fixed-size packet, zero-filling gaps to preserve timing.
25
+ */
26
+ readPacket(): Packet;
27
+ /** Mark the buffer as ending; drains then emits `end`. */
28
+ end(): void;
29
+ /** Clear internal buffers and state. */
30
+ reset(): void;
31
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_events_1 = require("node:events");
7
+ const packetPool_1 = __importDefault(require("./packetPool"));
8
+ const WAITING = 0;
9
+ const FILLING = 1;
10
+ const NORMAL = 2;
11
+ const DRAINING = 3;
12
+ const ENDING = 4;
13
+ const ENDED = 5;
14
+ /**
15
+ * Fixed-size circular buffer that smooths incoming PCM/ALAC chunks into fixed packet sizes.
16
+ * Emits status changes for buffering/playing/drain/end to drive UI + sync.
17
+ */
18
+ class CircularBuffer extends node_events_1.EventEmitter {
19
+ packetPool;
20
+ maxSize;
21
+ packetSize;
22
+ writable = true;
23
+ muted = false;
24
+ buffers = [];
25
+ currentSize = 0;
26
+ status = WAITING;
27
+ constructor(packetsInBuffer, packetSize) {
28
+ super();
29
+ this.packetPool = new packetPool_1.default(packetSize);
30
+ this.maxSize = packetsInBuffer * packetSize;
31
+ this.packetSize = packetSize;
32
+ }
33
+ /**
34
+ * Write a PCM/ALAC chunk into the buffer.
35
+ * Returns false when the buffer is full so upstream can throttle.
36
+ */
37
+ write(chunk) {
38
+ this.buffers.push(chunk);
39
+ this.currentSize += chunk.length;
40
+ if (this.status === ENDING || this.status === ENDED) {
41
+ throw new Error('Cannot write in buffer after closing it');
42
+ }
43
+ if (this.status === WAITING) {
44
+ this.emit('status', 'buffering');
45
+ this.status = FILLING;
46
+ }
47
+ if (this.status === FILLING && this.currentSize > this.maxSize / 2) {
48
+ this.status = NORMAL;
49
+ this.emit('status', 'playing');
50
+ }
51
+ if (this.currentSize >= this.maxSize) {
52
+ this.status = DRAINING;
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ /**
58
+ * Read the next fixed-size packet, zero-filling gaps to preserve timing.
59
+ */
60
+ readPacket() {
61
+ const packet = this.packetPool.getPacket();
62
+ if (this.status !== ENDING &&
63
+ this.status !== ENDED &&
64
+ (this.status === FILLING || this.currentSize < this.packetSize)) {
65
+ packet.pcm.fill(0);
66
+ if (this.status !== FILLING && this.status !== WAITING) {
67
+ this.status = FILLING;
68
+ this.emit('status', 'buffering');
69
+ }
70
+ }
71
+ else {
72
+ let offset = 0;
73
+ let remaining = this.packetSize;
74
+ while (remaining > 0) {
75
+ if (this.buffers.length === 0) {
76
+ packet.pcm.fill(0, offset);
77
+ remaining = 0;
78
+ break;
79
+ }
80
+ const first = this.buffers[0];
81
+ if (first.length <= remaining) {
82
+ first.copy(packet.pcm, offset);
83
+ offset += first.length;
84
+ remaining -= first.length;
85
+ this.buffers.shift();
86
+ }
87
+ else {
88
+ first.copy(packet.pcm, offset, 0, remaining);
89
+ this.buffers[0] = first.slice(remaining);
90
+ offset += remaining;
91
+ remaining = 0;
92
+ }
93
+ }
94
+ this.currentSize -= this.packetSize;
95
+ if (this.status === ENDING && this.currentSize <= 0) {
96
+ this.status = ENDED;
97
+ this.currentSize = 0;
98
+ this.emit('status', 'end');
99
+ }
100
+ if (this.status === DRAINING && this.currentSize < this.maxSize / 2) {
101
+ this.status = NORMAL;
102
+ this.emit('drain');
103
+ }
104
+ }
105
+ if (this.muted) {
106
+ packet.pcm.fill(0);
107
+ }
108
+ return packet;
109
+ }
110
+ /** Mark the buffer as ending; drains then emits `end`. */
111
+ end() {
112
+ if (this.status === FILLING) {
113
+ this.emit('status', 'playing');
114
+ }
115
+ this.status = ENDING;
116
+ }
117
+ /** Clear internal buffers and state. */
118
+ reset() {
119
+ this.buffers = [];
120
+ this.currentSize = 0;
121
+ this.status = WAITING;
122
+ }
123
+ }
124
+ exports.default = CircularBuffer;
@@ -0,0 +1,25 @@
1
+ export interface AirplayConfig {
2
+ user_agent: string;
3
+ udp_default_port: number;
4
+ frames_per_packet: number;
5
+ channels_per_frame: number;
6
+ bits_per_channel: number;
7
+ pcm_packet_size: number;
8
+ alac_packet_size: number;
9
+ packet_size: number;
10
+ packets_in_buffer: number;
11
+ coreaudio_min_level: number;
12
+ coreaudio_check_period: number;
13
+ coreaudio_preload: number;
14
+ sampling_rate: number;
15
+ sync_period: number;
16
+ stream_latency: number;
17
+ rtsp_timeout: number;
18
+ rtsp_heartbeat: number;
19
+ device_magic: number;
20
+ ntp_epoch: number;
21
+ iv_base64: string;
22
+ rsa_aeskey_base64: string;
23
+ }
24
+ export declare const config: AirplayConfig;
25
+ export default config;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.config = void 0;
4
+ const numUtil_1 = require("./numUtil");
5
+ exports.config = {
6
+ user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)',
7
+ udp_default_port: 54621, // preferred starting port in AirTunes v2
8
+ frames_per_packet: 352, // samples per frames in ALAC packets
9
+ channels_per_frame: 2, // always stereo in AirTunes v2
10
+ bits_per_channel: 16, // -> 2 bytes per channel
11
+ pcm_packet_size: 352 * 2 * 2, // frames*channels*bytes
12
+ alac_packet_size: 352 * 2 * 2 + 8, // pcm payload + alac header/footer
13
+ packet_size: 352 * 2 * 2, // active packet size (depends on input codec)
14
+ packets_in_buffer: 260, // ~2.1s of audio (matches MA's ~2000ms buffer)
15
+ coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it
16
+ coreaudio_check_period: 2000, // CoreAudio buffer level check period
17
+ coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
18
+ sampling_rate: 44100, // fixed by AirTunes v2
19
+ sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly
20
+ stream_latency: 200, // audio UDP packets are flushed in bursts periodically
21
+ rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout
22
+ rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat.
23
+ device_magic: (0, numUtil_1.randomInt)(9),
24
+ ntp_epoch: 0x83aa7e80,
25
+ iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
26
+ rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
27
+ };
28
+ exports.default = exports.config;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Minimal HTTP/1.1 client for plain TCP connections (no TLS).
3
+ * Used for AirPlay control endpoints where lightweight parsing is sufficient.
4
+ */
5
+ type Headers = Record<string, string>;
6
+ export type MessageObject = {
7
+ method?: string;
8
+ path?: string;
9
+ statusCode?: number;
10
+ headers: Headers;
11
+ body?: Buffer;
12
+ };
13
+ export interface HttpClientApi {
14
+ connect(host: string, port?: number): Promise<void>;
15
+ request(method: string, path: string, headers?: Headers, body?: Buffer): Promise<MessageObject | null>;
16
+ close(): void;
17
+ }
18
+ declare const createHttpClient: () => HttpClientApi;
19
+ export default createHttpClient;