@lox-audioserver/node-airplay-sender 0.4.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 +93 -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 +43 -0
  9. package/dist/core/audioOut.js +220 -0
  10. package/dist/core/deviceAirtunes.d.ts +76 -0
  11. package/dist/core/deviceAirtunes.js +536 -0
  12. package/dist/core/devices.d.ts +50 -0
  13. package/dist/core/devices.js +221 -0
  14. package/dist/core/index.d.ts +56 -0
  15. package/dist/core/index.js +144 -0
  16. package/dist/core/rtsp.d.ts +12 -0
  17. package/dist/core/rtsp.js +1678 -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 +44 -0
  21. package/dist/core/udpServers.js +244 -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 +220 -0
  26. package/dist/esm/core/deviceAirtunes.js +536 -0
  27. package/dist/esm/core/devices.js +221 -0
  28. package/dist/esm/core/index.js +144 -0
  29. package/dist/esm/core/rtsp.js +1678 -0
  30. package/dist/esm/core/srp.js +128 -0
  31. package/dist/esm/core/udpServers.js +244 -0
  32. package/dist/esm/homekit/credentials.js +109 -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 +310 -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 +132 -0
  41. package/dist/esm/utils/config.js +49 -0
  42. package/dist/esm/utils/http.js +148 -0
  43. package/dist/esm/utils/ntp.js +56 -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 +31 -0
  48. package/dist/homekit/credentials.js +109 -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 +121 -0
  56. package/dist/index.js +310 -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 +32 -0
  62. package/dist/utils/circularBuffer.js +132 -0
  63. package/dist/utils/config.d.ts +42 -0
  64. package/dist/utils/config.js +49 -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 +21 -0
  68. package/dist/utils/ntp.js +56 -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 +71 -0
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Type Length Value encoding/decoding, used by HAP as a wire format.
5
+ * https://en.wikipedia.org/wiki/Type-length-value
6
+ */
7
+ const Tag = {
8
+ PairingMethod: 0x00,
9
+ Username: 0x01,
10
+ Salt: 0x02,
11
+ // could be either the SRP client public key (384 bytes) or the ED25519 public key (32 bytes), depending on context
12
+ PublicKey: 0x03,
13
+ Proof: 0x04,
14
+ EncryptedData: 0x05,
15
+ Sequence: 0x06,
16
+ ErrorCode: 0x07,
17
+ BackOff: 0x08,
18
+ Signature: 0x0a,
19
+ MFiCertificate: 0x09,
20
+ MFiSignature: 0x0a,
21
+ Flags: 0x13,
22
+ };
23
+ function encodeOne(type, data) {
24
+ let bufferData;
25
+ if (typeof data === 'number') {
26
+ bufferData = Buffer.from([data]);
27
+ }
28
+ else if (typeof data === 'string') {
29
+ bufferData = Buffer.from(data);
30
+ }
31
+ else {
32
+ bufferData = data;
33
+ }
34
+ if (bufferData.length <= 255) {
35
+ return Buffer.concat([Buffer.from([type, bufferData.length]), bufferData]);
36
+ }
37
+ let leftLength = bufferData.length;
38
+ let tempBuffer = Buffer.alloc(0);
39
+ let currentStart = 0;
40
+ for (; leftLength > 0;) {
41
+ if (leftLength >= 255) {
42
+ tempBuffer = Buffer.concat([
43
+ tempBuffer,
44
+ Buffer.from([type, 0xff]),
45
+ bufferData.slice(currentStart, currentStart + 255),
46
+ ]);
47
+ leftLength -= 255;
48
+ currentStart += 255;
49
+ }
50
+ else {
51
+ tempBuffer = Buffer.concat([
52
+ tempBuffer,
53
+ Buffer.from([type, leftLength]),
54
+ bufferData.slice(currentStart, currentStart + leftLength),
55
+ ]);
56
+ leftLength = 0;
57
+ }
58
+ }
59
+ return tempBuffer;
60
+ }
61
+ function encode(type, data, ...args) {
62
+ const encodedTLVBuffer = encodeOne(type, data);
63
+ if (args.length === 0) {
64
+ return encodedTLVBuffer;
65
+ }
66
+ const nextType = args[0];
67
+ const nextData = args[1];
68
+ const remaining = args.slice(2);
69
+ const remainingTLVBuffer = encode(nextType, nextData, ...remaining);
70
+ return Buffer.concat([encodedTLVBuffer, remainingTLVBuffer]);
71
+ }
72
+ function decode(data) {
73
+ const objects = {};
74
+ let leftLength = data.length;
75
+ let currentIndex = 0;
76
+ for (; leftLength > 0;) {
77
+ const type = data[currentIndex];
78
+ const length = data[currentIndex + 1];
79
+ currentIndex += 2;
80
+ leftLength -= 2;
81
+ const newData = data.slice(currentIndex, currentIndex + length);
82
+ if (objects[type]) {
83
+ objects[type] = Buffer.concat([objects[type], newData]);
84
+ }
85
+ else {
86
+ objects[type] = newData;
87
+ }
88
+ currentIndex += length;
89
+ leftLength -= length;
90
+ }
91
+ return objects;
92
+ }
93
+ exports.default = {
94
+ Tag,
95
+ encode,
96
+ decode,
97
+ };
@@ -0,0 +1,121 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Readable } from 'node:stream';
3
+ import { type AirplayConfig } from './utils/config';
4
+ import { type NtpTimestampInput } from './utils/ntp';
5
+ /**
6
+ * Configuration for a single AirPlay sender.
7
+ */
8
+ export interface LoxAirplaySenderOptions {
9
+ host: string;
10
+ /** RAOP port (defaults to 5000). */
11
+ port?: number;
12
+ /** Display name shown on the receiver. */
13
+ name?: string;
14
+ /** AirPlay 1 password; null disables auth. */
15
+ password?: string | null;
16
+ /** Initial volume 0–100 (default 50). */
17
+ volume?: number;
18
+ /** Explicit RAOP mode; defaults based on airplay2 flag. */
19
+ mode?: number;
20
+ /** Additional TXT records to advertise. */
21
+ txt?: string[];
22
+ /** Force ALAC encoding even when input is ALAC. */
23
+ forceAlac?: boolean;
24
+ /** Enable ALAC encoding pipeline. */
25
+ alacEncoding?: boolean;
26
+ /** Input format; pcm triggers encoding, alac passes through. */
27
+ inputCodec?: 'pcm' | 'alac';
28
+ /** Enable AirPlay 2 authentication + flags. */
29
+ airplay2?: boolean;
30
+ /** Emit verbose transport logs. */
31
+ debug?: boolean;
32
+ /** Optional unix ms start time for synced playback. */
33
+ startTimeMs?: number;
34
+ /** Optional absolute NTP start time (uint64: sec<<32 | frac). */
35
+ startTimeNtp?: NtpTimestampInput;
36
+ /** Logger hook for internal messages. */
37
+ log?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: unknown) => void;
38
+ /** Override transport/buffer tuning without patching the module. */
39
+ config?: Partial<AirplayConfig>;
40
+ /** Optional override for per-session SSRC/device magic. */
41
+ deviceMagic?: number;
42
+ /** Optional override for resend buffer size (packets). */
43
+ resendBufferSize?: number;
44
+ /** Optional mute window (ms) after underrun to smooth recovery. */
45
+ underrunMuteMs?: number;
46
+ }
47
+ /**
48
+ * Metadata sent to receivers for UI display.
49
+ */
50
+ export interface AirplayMetadata {
51
+ title?: string;
52
+ artist?: string;
53
+ album?: string;
54
+ cover?: {
55
+ data: Buffer;
56
+ mime?: string;
57
+ };
58
+ coverUrl?: string;
59
+ elapsedMs?: number;
60
+ durationMs?: number;
61
+ }
62
+ export interface LoxAirplayEvent {
63
+ event: string;
64
+ message?: string;
65
+ detail?: any;
66
+ }
67
+ export declare class LoxAirplaySender extends EventEmitter {
68
+ private airtunes;
69
+ private deviceKey;
70
+ private started;
71
+ private source;
72
+ private log?;
73
+ private lastTrackKey;
74
+ private lastCoverKey;
75
+ private lastProgressKey;
76
+ private lastCoverUrl;
77
+ private coverFetch?;
78
+ private artworkTimer?;
79
+ private pendingArtwork?;
80
+ private lastTrackChangeAt;
81
+ /**
82
+ * Create + start a sender for a single AirPlay device.
83
+ * Returns true when the pipeline initializes; safe to call multiple times to restart.
84
+ */
85
+ start(options: LoxAirplaySenderOptions, onEvent?: (event: LoxAirplayEvent) => void): boolean;
86
+ /**
87
+ * Push raw PCM or ALAC frames into the stream.
88
+ */
89
+ sendPcm(chunk: Buffer): void;
90
+ /**
91
+ * Pipe a readable stream into the sender; auto-stops on end/error.
92
+ */
93
+ pipeStream(stream: Readable): void;
94
+ /** Adjust receiver volume (0–100). */
95
+ setVolume(volume: number): void;
96
+ /** Update track metadata immediately without artwork/progress. */
97
+ setTrackInfo(title: string, artist?: string, album?: string): void;
98
+ /** Send cover art immediately. */
99
+ setArtwork(art: Buffer, contentType?: string): void;
100
+ /** Send playback progress in seconds (elapsed, duration). */
101
+ setProgress(progress: number, duration: number): void;
102
+ /**
103
+ * Convenience to send track info, cover (buffer or URL), and progress.
104
+ * Deduplicates payloads and staggers artwork on track changes.
105
+ */
106
+ setMetadata(payload: AirplayMetadata): Promise<void>;
107
+ /** Provide a passcode when a receiver requests it. */
108
+ setPasscode(passcode: string): void;
109
+ /**
110
+ * Stop streaming and tear down state/sockets. Safe to call multiple times.
111
+ */
112
+ stop(): void;
113
+ private fetchCover;
114
+ private sendArtworkNow;
115
+ private queueArtwork;
116
+ }
117
+ /**
118
+ * Convenience helper to construct + start a sender in one call.
119
+ */
120
+ export declare function start(options: LoxAirplaySenderOptions, onEvent?: (event: LoxAirplayEvent) => void): LoxAirplaySender;
121
+ export default LoxAirplaySender;
package/dist/index.js ADDED
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.LoxAirplaySender = void 0;
40
+ exports.start = start;
41
+ const node_events_1 = require("node:events");
42
+ // Airtunes implementation (node_airtunes2 port) in src/core.
43
+ const index_1 = __importDefault(require("./core/index"));
44
+ const config_1 = __importStar(require("./utils/config"));
45
+ const ntp_1 = require("./utils/ntp");
46
+ class LoxAirplaySender extends node_events_1.EventEmitter {
47
+ airtunes = null;
48
+ deviceKey = null;
49
+ started = false;
50
+ source = null;
51
+ log;
52
+ lastTrackKey = null;
53
+ lastCoverKey = null;
54
+ lastProgressKey = null;
55
+ lastCoverUrl = null;
56
+ coverFetch;
57
+ artworkTimer;
58
+ pendingArtwork;
59
+ lastTrackChangeAt = 0;
60
+ /**
61
+ * Create + start a sender for a single AirPlay device.
62
+ * Returns true when the pipeline initializes; safe to call multiple times to restart.
63
+ */
64
+ start(options, onEvent) {
65
+ if (this.started) {
66
+ this.stop();
67
+ }
68
+ this.log = options.log;
69
+ if (options.config) {
70
+ (0, config_1.applyConfig)(options.config);
71
+ }
72
+ const inputCodec = options.inputCodec ?? 'pcm';
73
+ config_1.default.packet_size = inputCodec === 'alac' ? config_1.default.alac_packet_size : config_1.default.pcm_packet_size;
74
+ this.airtunes = new index_1.default({
75
+ packetSize: config_1.default.packet_size,
76
+ startTimeMs: options.startTimeMs,
77
+ startTimeNtp: options.startTimeNtp ? (0, ntp_1.toNtpTimestamp)(options.startTimeNtp) : undefined,
78
+ deviceMagic: options.deviceMagic,
79
+ resendBufferSize: options.resendBufferSize,
80
+ underrunMuteMs: options.underrunMuteMs,
81
+ config: options.config,
82
+ });
83
+ this.airtunes.on('device', (key, status, desc) => {
84
+ onEvent?.({ event: 'device', message: status, detail: { key, desc } });
85
+ });
86
+ this.airtunes.on('buffer', (status) => {
87
+ onEvent?.({ event: 'buffer', message: status });
88
+ });
89
+ this.airtunes.on('error', (err) => {
90
+ onEvent?.({ event: 'error', message: err instanceof Error ? err.message : String(err) });
91
+ });
92
+ this.airtunes.on('metrics', (detail) => {
93
+ onEvent?.({ event: 'metrics', detail });
94
+ });
95
+ const dev = this.airtunes.add(options.host, {
96
+ port: options.port,
97
+ name: options.name,
98
+ password: options.password ?? null,
99
+ volume: options.volume ?? 50,
100
+ mode: options.mode ?? (options.airplay2 ? 2 : 0),
101
+ txt: options.txt ?? [],
102
+ forceAlac: options.forceAlac ?? true,
103
+ alacEncoding: options.alacEncoding ?? true,
104
+ inputCodec,
105
+ airplay2: options.airplay2 ?? false,
106
+ debug: options.debug ?? false,
107
+ log: options.log,
108
+ });
109
+ this.deviceKey = dev?.key ?? `${options.host}:${options.port ?? 5000}`;
110
+ this.started = true;
111
+ return true;
112
+ }
113
+ /**
114
+ * Push raw PCM or ALAC frames into the stream.
115
+ */
116
+ sendPcm(chunk) {
117
+ if (!this.airtunes || !this.started)
118
+ return;
119
+ this.airtunes.write(chunk);
120
+ }
121
+ /**
122
+ * Pipe a readable stream into the sender; auto-stops on end/error.
123
+ */
124
+ pipeStream(stream) {
125
+ if (!this.airtunes || !this.started)
126
+ return;
127
+ this.source = stream;
128
+ stream.on('data', (chunk) => this.sendPcm(chunk));
129
+ stream.on('end', () => this.stop());
130
+ stream.on('error', () => this.stop());
131
+ }
132
+ /** Adjust receiver volume (0–100). */
133
+ setVolume(volume) {
134
+ if (!this.airtunes || !this.deviceKey)
135
+ return;
136
+ this.airtunes.setVolume(this.deviceKey, volume);
137
+ }
138
+ /** Update track metadata immediately without artwork/progress. */
139
+ setTrackInfo(title, artist, album) {
140
+ if (!this.airtunes || !this.deviceKey)
141
+ return;
142
+ this.airtunes.setTrackInfo(this.deviceKey, title, artist, album);
143
+ }
144
+ /** Send cover art immediately. */
145
+ setArtwork(art, contentType) {
146
+ if (!this.airtunes || !this.deviceKey)
147
+ return;
148
+ this.airtunes.setArtwork(this.deviceKey, art, contentType);
149
+ }
150
+ /** Send playback progress in seconds (elapsed, duration). */
151
+ setProgress(progress, duration) {
152
+ if (!this.airtunes || !this.deviceKey)
153
+ return;
154
+ this.airtunes.setProgress(this.deviceKey, progress, duration);
155
+ }
156
+ /**
157
+ * Convenience to send track info, cover (buffer or URL), and progress.
158
+ * Deduplicates payloads and staggers artwork on track changes.
159
+ */
160
+ async setMetadata(payload) {
161
+ if (!this.airtunes || !this.deviceKey)
162
+ return;
163
+ const title = payload.title ?? '';
164
+ const artist = payload.artist ?? '';
165
+ const album = payload.album ?? '';
166
+ const trackKey = `${title}::${artist}::${album}`;
167
+ const trackChanged = Boolean(title && trackKey !== this.lastTrackKey);
168
+ if (trackChanged) {
169
+ this.setTrackInfo(title, artist, album);
170
+ this.lastTrackKey = trackKey;
171
+ this.lastCoverKey = null;
172
+ this.lastCoverUrl = null;
173
+ this.lastTrackChangeAt = Date.now();
174
+ }
175
+ let coverPayload = payload.cover;
176
+ const coverUrl = payload.coverUrl;
177
+ if (!coverPayload?.data && coverUrl) {
178
+ if (coverUrl !== this.lastCoverUrl && !this.coverFetch) {
179
+ this.lastCoverUrl = coverUrl;
180
+ this.lastCoverKey = null;
181
+ this.coverFetch = this.fetchCover(coverUrl).finally(() => {
182
+ this.coverFetch = undefined;
183
+ });
184
+ }
185
+ if (this.coverFetch) {
186
+ coverPayload = (await this.coverFetch) ?? undefined;
187
+ }
188
+ }
189
+ if (coverPayload?.data) {
190
+ const coverKey = coverUrl
191
+ ? `${coverUrl}:${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`
192
+ : `${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`;
193
+ if (coverKey !== this.lastCoverKey) {
194
+ if (trackChanged) {
195
+ this.queueArtwork(coverPayload, coverKey, 200);
196
+ }
197
+ else {
198
+ this.sendArtworkNow(coverPayload, coverKey);
199
+ }
200
+ }
201
+ }
202
+ const durationInput = typeof payload.durationMs === 'number' && payload.durationMs > 0 ? payload.durationMs : null;
203
+ const elapsedInput = typeof payload.elapsedMs === 'number' && payload.elapsedMs >= 0 ? payload.elapsedMs : null;
204
+ const durationSec = durationInput !== null ? Math.floor(durationInput > 1000 ? durationInput / 1000 : durationInput) : null;
205
+ const elapsedSecRaw = elapsedInput !== null ? Math.floor(elapsedInput > 1000 ? elapsedInput / 1000 : elapsedInput) : null;
206
+ if (durationSec !== null && elapsedSecRaw !== null && durationSec > 0) {
207
+ const elapsedSec = Math.min(Math.max(0, elapsedSecRaw), durationSec);
208
+ const progressKey = `${elapsedSec}/${durationSec}`;
209
+ if (progressKey !== this.lastProgressKey) {
210
+ this.setProgress(elapsedSec, durationSec);
211
+ this.lastProgressKey = progressKey;
212
+ }
213
+ }
214
+ }
215
+ /** Provide a passcode when a receiver requests it. */
216
+ setPasscode(passcode) {
217
+ if (!this.airtunes || !this.deviceKey)
218
+ return;
219
+ this.airtunes.setPasscode(this.deviceKey, passcode);
220
+ }
221
+ /**
222
+ * Stop streaming and tear down state/sockets. Safe to call multiple times.
223
+ */
224
+ stop() {
225
+ if (this.source) {
226
+ try {
227
+ this.source.destroy();
228
+ }
229
+ catch {
230
+ // ignore
231
+ }
232
+ this.source = null;
233
+ }
234
+ this.lastTrackKey = null;
235
+ this.lastCoverKey = null;
236
+ this.lastProgressKey = null;
237
+ this.lastCoverUrl = null;
238
+ this.lastTrackChangeAt = 0;
239
+ this.pendingArtwork = undefined;
240
+ if (this.artworkTimer) {
241
+ clearTimeout(this.artworkTimer);
242
+ this.artworkTimer = undefined;
243
+ }
244
+ if (this.airtunes) {
245
+ if (this.deviceKey) {
246
+ this.airtunes.stop(this.deviceKey);
247
+ }
248
+ this.airtunes.stopAll?.(() => undefined);
249
+ this.airtunes.end?.();
250
+ }
251
+ this.airtunes = null;
252
+ this.deviceKey = null;
253
+ this.started = false;
254
+ }
255
+ async fetchCover(url) {
256
+ const controller = new AbortController();
257
+ const timeout = setTimeout(() => controller.abort(), 4000);
258
+ try {
259
+ const response = await fetch(url, { signal: controller.signal });
260
+ if (!response.ok) {
261
+ this.log?.('warn', 'airplay cover fetch failed', { status: response.status, url });
262
+ return null;
263
+ }
264
+ const mime = response.headers.get('content-type') || undefined;
265
+ const buffer = Buffer.from(await response.arrayBuffer());
266
+ if (!buffer.length) {
267
+ this.log?.('warn', 'airplay cover fetch empty', { url });
268
+ return null;
269
+ }
270
+ return { data: buffer, mime };
271
+ }
272
+ catch {
273
+ this.log?.('warn', 'airplay cover fetch error', { url });
274
+ return null;
275
+ }
276
+ finally {
277
+ clearTimeout(timeout);
278
+ }
279
+ }
280
+ sendArtworkNow(payload, coverKey) {
281
+ this.setArtwork(payload.data, payload.mime);
282
+ this.lastCoverKey = coverKey;
283
+ }
284
+ queueArtwork(payload, coverKey, delayMs) {
285
+ this.pendingArtwork = { ...payload, key: coverKey };
286
+ if (this.artworkTimer) {
287
+ clearTimeout(this.artworkTimer);
288
+ }
289
+ this.artworkTimer = setTimeout(() => {
290
+ this.artworkTimer = undefined;
291
+ const pending = this.pendingArtwork;
292
+ this.pendingArtwork = undefined;
293
+ if (!pending)
294
+ return;
295
+ if (pending.key === this.lastCoverKey)
296
+ return;
297
+ this.sendArtworkNow({ data: pending.data, mime: pending.mime }, pending.key);
298
+ }, delayMs);
299
+ }
300
+ }
301
+ exports.LoxAirplaySender = LoxAirplaySender;
302
+ /**
303
+ * Convenience helper to construct + start a sender in one call.
304
+ */
305
+ function start(options, onEvent) {
306
+ const sender = new LoxAirplaySender();
307
+ sender.start(options, onEvent);
308
+ return sender;
309
+ }
310
+ 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,32 @@
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
+ private hadUnderrun;
18
+ constructor(packetsInBuffer: number, packetSize: number);
19
+ /**
20
+ * Write a PCM/ALAC chunk into the buffer.
21
+ * Returns false when the buffer is full so upstream can throttle.
22
+ */
23
+ write(chunk: Buffer): boolean;
24
+ /**
25
+ * Read the next fixed-size packet, zero-filling gaps to preserve timing.
26
+ */
27
+ readPacket(): Packet;
28
+ /** Mark the buffer as ending; drains then emits `end`. */
29
+ end(): void;
30
+ /** Clear internal buffers and state. */
31
+ reset(): void;
32
+ }