@lox-audioserver/node-airplay-sender 0.4.4 → 0.4.5
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/README.md +2 -8
- package/dist/core/audioOut.d.ts +1 -14
- package/dist/core/audioOut.js +16 -152
- package/dist/core/deviceAirtunes.d.ts +2 -4
- package/dist/core/deviceAirtunes.js +18 -51
- package/dist/core/devices.js +2 -14
- package/dist/core/index.d.ts +3 -10
- package/dist/core/index.js +5 -13
- package/dist/core/rtsp.js +15 -51
- package/dist/core/udpServers.d.ts +1 -19
- package/dist/core/udpServers.js +3 -94
- package/dist/esm/core/audioOut.js +16 -152
- package/dist/esm/core/deviceAirtunes.js +18 -51
- package/dist/esm/core/devices.js +2 -14
- package/dist/esm/core/index.js +5 -13
- package/dist/esm/core/rtsp.js +15 -51
- package/dist/esm/core/udpServers.js +3 -94
- package/dist/esm/homekit/credentials.js +5 -15
- package/dist/esm/index.js +0 -5
- package/dist/esm/utils/circularBuffer.js +0 -8
- package/dist/esm/utils/config.js +2 -12
- package/dist/esm/utils/ntp.js +12 -41
- package/dist/homekit/credentials.d.ts +1 -2
- package/dist/homekit/credentials.js +5 -15
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -5
- package/dist/utils/circularBuffer.d.ts +0 -1
- package/dist/utils/circularBuffer.js +0 -8
- package/dist/utils/config.d.ts +0 -10
- package/dist/utils/config.js +2 -12
- package/dist/utils/ntp.d.ts +1 -15
- package/dist/utils/ntp.js +12 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lox-airplay-sender
|
|
2
2
|
|
|
3
|
-
AirPlay sender
|
|
3
|
+
AirPlay sender (RAOP/AirPlay 1 + AirPlay 2 auth) refactored from node_airtunes2 into a modern, typed TypeScript module. It owns the RTSP/UDP pipeline, ALAC encoding, and metadata handling with no native dependencies.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
- Node.js 18+
|
|
@@ -55,12 +55,6 @@ Creates and starts a sender for one AirPlay device. Returns the instance so you
|
|
|
55
55
|
- `inputCodec` (`"pcm"` | `"alac"`) Defaults to `"pcm"`.
|
|
56
56
|
- `airplay2` (boolean) Enable AirPlay 2 auth/flags; default false.
|
|
57
57
|
- `startTimeMs` (number) Unix ms to align playback across devices.
|
|
58
|
-
- `startTimeNtp` (bigint | number | `{ sec, frac }`) Absolute NTP start time (sec<<32|frac) for tighter sync.
|
|
59
|
-
- `deviceMagic` (number) Override per-session SSRC/device ID used in RTP/RTCP.
|
|
60
|
-
- `resendBufferSize` (number) Override resend cache size (packets); defaults to 1000.
|
|
61
|
-
- `underrunMuteMs` (number) Mute window in ms after an underrun to smooth recovery; defaults to 50.
|
|
62
|
-
- `sendRtcpXr` (boolean) Emit RTCP Extended Reports (RRT) alongside sync/SSR; default false.
|
|
63
|
-
- `debugDump` (boolean) Log RTSP/RTCP traffic for debugging; default false.
|
|
64
58
|
- `debug` (boolean) Verbose logging from the transport stack.
|
|
65
59
|
- `log` `(level, message, data?) => void` Hook for library logs.
|
|
66
60
|
- `config` (partial) Override buffer/sync/RTSP tuning at runtime (see `src/utils/config.ts` for keys like `packets_in_buffer`, `stream_latency`, `sync_period`, retry/backoff, etc.).
|
package/dist/core/audioOut.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type CircularBuffer from '../utils/circularBuffer';
|
|
|
3
3
|
type DevicesEmitter = EventEmitter & {
|
|
4
4
|
on(event: 'airtunes_devices', listener: (hasAirTunes: boolean) => void): DevicesEmitter;
|
|
5
5
|
on(event: 'need_sync', listener: () => void): DevicesEmitter;
|
|
6
|
-
emit(event: 'underrun'): boolean;
|
|
7
6
|
};
|
|
8
7
|
/**
|
|
9
8
|
* Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
|
|
@@ -11,33 +10,21 @@ type DevicesEmitter = EventEmitter & {
|
|
|
11
10
|
*/
|
|
12
11
|
export default class AudioOut extends EventEmitter {
|
|
13
12
|
private lastSeq;
|
|
14
|
-
private lastWireSeq;
|
|
15
13
|
private hasAirTunes;
|
|
16
14
|
private rtpTimeRef;
|
|
17
15
|
private startTimeMs?;
|
|
18
|
-
private startTimeNtp?;
|
|
19
16
|
private latencyFrames;
|
|
20
17
|
private latencyApplied;
|
|
21
|
-
private seqOffset;
|
|
22
|
-
private tsOffset;
|
|
23
|
-
private syncOffsetFrames;
|
|
24
|
-
private deviceMagic;
|
|
25
|
-
private packetCount;
|
|
26
|
-
private octetCount;
|
|
27
|
-
private readonly frameDurationMs;
|
|
28
|
-
private muteUntilMs;
|
|
29
18
|
/**
|
|
30
19
|
* Begin pulling from the buffer and emitting packets at the configured cadence.
|
|
31
20
|
* @param devices Device manager for sync events.
|
|
32
21
|
* @param circularBuffer PCM/ALAC buffer.
|
|
33
22
|
* @param startTimeMs Optional unix ms to align playback.
|
|
34
|
-
* @param startTimeNtp Optional NTP uint64 (sec<<32|frac) to align playback.
|
|
35
23
|
*/
|
|
36
|
-
init(devices: DevicesEmitter, circularBuffer: CircularBuffer, startTimeMs?: number
|
|
24
|
+
init(devices: DevicesEmitter, circularBuffer: CircularBuffer, startTimeMs?: number): void;
|
|
37
25
|
/**
|
|
38
26
|
* Apply latency (in audio frames) when aligning start time.
|
|
39
27
|
*/
|
|
40
28
|
setLatencyFrames(latencyFrames: number): void;
|
|
41
|
-
private handleUnderrun;
|
|
42
29
|
}
|
|
43
30
|
export {};
|
package/dist/core/audioOut.js
CHANGED
|
@@ -1,194 +1,69 @@
|
|
|
1
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
2
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
4
|
};
|
|
38
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
6
|
const node_events_1 = require("node:events");
|
|
40
|
-
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
41
7
|
const config_1 = __importDefault(require("../utils/config"));
|
|
42
8
|
const numUtil_1 = require("../utils/numUtil");
|
|
43
|
-
const ntp_1 = __importStar(require("../utils/ntp"));
|
|
44
9
|
const SEQ_NUM_WRAP = Math.pow(2, 16);
|
|
45
|
-
const FRAC_PER_SEC = 0x1_0000_0000;
|
|
46
10
|
/**
|
|
47
11
|
* Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
|
|
48
12
|
* Emits `packet` events for devices and sync requests (`need_sync`) at intervals.
|
|
49
13
|
*/
|
|
50
14
|
class AudioOut extends node_events_1.EventEmitter {
|
|
51
15
|
lastSeq = -1;
|
|
52
|
-
lastWireSeq = 0;
|
|
53
16
|
hasAirTunes = false;
|
|
54
|
-
rtpTimeRef =
|
|
17
|
+
rtpTimeRef = Date.now();
|
|
55
18
|
startTimeMs;
|
|
56
|
-
startTimeNtp;
|
|
57
19
|
latencyFrames = 0;
|
|
58
20
|
latencyApplied = false;
|
|
59
|
-
seqOffset = 0;
|
|
60
|
-
tsOffset = 0;
|
|
61
|
-
syncOffsetFrames = 0;
|
|
62
|
-
deviceMagic = 0;
|
|
63
|
-
packetCount = 0;
|
|
64
|
-
octetCount = 0;
|
|
65
|
-
frameDurationMs = (config_1.default.frames_per_packet / config_1.default.sampling_rate) * 1000;
|
|
66
|
-
muteUntilMs = 0;
|
|
67
21
|
/**
|
|
68
22
|
* Begin pulling from the buffer and emitting packets at the configured cadence.
|
|
69
23
|
* @param devices Device manager for sync events.
|
|
70
24
|
* @param circularBuffer PCM/ALAC buffer.
|
|
71
25
|
* @param startTimeMs Optional unix ms to align playback.
|
|
72
|
-
* @param startTimeNtp Optional NTP uint64 (sec<<32|frac) to align playback.
|
|
73
26
|
*/
|
|
74
|
-
init(devices, circularBuffer, startTimeMs
|
|
27
|
+
init(devices, circularBuffer, startTimeMs) {
|
|
75
28
|
this.startTimeMs =
|
|
76
29
|
typeof startTimeMs === 'number' && Number.isFinite(startTimeMs)
|
|
77
30
|
? startTimeMs
|
|
78
31
|
: undefined;
|
|
79
|
-
this.
|
|
80
|
-
this.seqOffset = Math.floor(Math.random() * SEQ_NUM_WRAP);
|
|
81
|
-
this.tsOffset = Math.floor(Math.random() * 0xffffffff);
|
|
82
|
-
this.syncOffsetFrames = this.tsOffset / config_1.default.frames_per_packet;
|
|
83
|
-
this.deviceMagic = typeof deviceMagic === 'number' ? deviceMagic : config_1.default.device_magic;
|
|
84
|
-
if (typeof underrunMuteMs === 'number') {
|
|
85
|
-
config_1.default.underrun_mute_ms = underrunMuteMs;
|
|
86
|
-
}
|
|
87
|
-
circularBuffer.on('underrun', () => this.handleUnderrun());
|
|
88
|
-
const monoNow = node_perf_hooks_1.performance.now();
|
|
89
|
-
const wallToMonoOffset = Date.now() - monoNow;
|
|
90
|
-
if (typeof this.startTimeNtp === 'bigint' || typeof this.startTimeNtp === 'number') {
|
|
91
|
-
const { sec, frac } = (0, ntp_1.parseNtpTimestamp)(this.startTimeNtp);
|
|
92
|
-
const unixMs = (sec - config_1.default.ntp_epoch) * 1000 + Math.floor((frac * 1000) / FRAC_PER_SEC);
|
|
93
|
-
const nowMs = config_1.default.use_monotonic_clock ? monoNow : Date.now();
|
|
94
|
-
const delta = unixMs - nowMs;
|
|
95
|
-
// If target is too close/past, clamp to now.
|
|
96
|
-
this.rtpTimeRef = delta > -config_1.default.stream_latency ? unixMs - wallToMonoOffset : nowMs;
|
|
97
|
-
}
|
|
98
|
-
else if (this.startTimeMs !== undefined) {
|
|
99
|
-
const nowMs = config_1.default.use_monotonic_clock ? monoNow : Date.now();
|
|
100
|
-
const delta = this.startTimeMs - nowMs;
|
|
101
|
-
this.rtpTimeRef = delta > -config_1.default.stream_latency ? this.startTimeMs - wallToMonoOffset : nowMs;
|
|
102
|
-
}
|
|
103
|
-
else if (config_1.default.use_monotonic_clock) {
|
|
104
|
-
this.rtpTimeRef = monoNow;
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
this.rtpTimeRef = Date.now();
|
|
108
|
-
}
|
|
32
|
+
this.rtpTimeRef = this.startTimeMs ?? Date.now();
|
|
109
33
|
devices.on('airtunes_devices', (hasAirTunes) => {
|
|
110
34
|
this.hasAirTunes = hasAirTunes;
|
|
111
35
|
});
|
|
112
36
|
devices.on('need_sync', () => {
|
|
113
|
-
this.emit('need_sync',
|
|
37
|
+
this.emit('need_sync', this.lastSeq);
|
|
114
38
|
});
|
|
115
|
-
const syncEvery = config_1.default.sync_period && config_1.default.sync_period > 0
|
|
116
|
-
? config_1.default.sync_period
|
|
117
|
-
: Math.max(1, Math.round(config_1.default.sampling_rate / config_1.default.frames_per_packet));
|
|
118
39
|
const sendPacket = (seq) => {
|
|
119
|
-
const wireSeq = (this.seqOffset + seq) % SEQ_NUM_WRAP;
|
|
120
40
|
const packet = circularBuffer.readPacket();
|
|
121
|
-
packet.seq =
|
|
122
|
-
packet.timestamp = (0, numUtil_1.low32)(
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
if (this.hasAirTunes && seq % syncEvery === 0) {
|
|
126
|
-
this.emit('need_sync', {
|
|
127
|
-
seq: wireSeq,
|
|
128
|
-
tsOffsetFrames: this.syncOffsetFrames,
|
|
129
|
-
rtcp: {
|
|
130
|
-
rtpTimestamp: packet.timestamp,
|
|
131
|
-
ntp: ntp_1.default.timestamp(),
|
|
132
|
-
packetCount: this.packetCount,
|
|
133
|
-
octetCount: this.octetCount,
|
|
134
|
-
xr: { ntp: ntp_1.default.timestamp() },
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
|
|
41
|
+
packet.seq = seq % SEQ_NUM_WRAP;
|
|
42
|
+
packet.timestamp = (0, numUtil_1.low32)(seq * config_1.default.frames_per_packet + 2 * config_1.default.sampling_rate);
|
|
43
|
+
if (this.hasAirTunes && seq % config_1.default.sync_period === 0) {
|
|
44
|
+
this.emit('need_sync', seq);
|
|
138
45
|
const expectedTimeMs = this.rtpTimeRef +
|
|
139
|
-
((
|
|
140
|
-
const deltaMs =
|
|
141
|
-
this.emit('metrics', { type: 'sync', seq
|
|
46
|
+
((seq * config_1.default.frames_per_packet) / config_1.default.sampling_rate) * 1000;
|
|
47
|
+
const deltaMs = Date.now() - expectedTimeMs;
|
|
48
|
+
this.emit('metrics', { type: 'sync', seq, deltaMs, latencyFrames: this.latencyFrames });
|
|
142
49
|
}
|
|
143
50
|
this.emit('packet', packet);
|
|
144
|
-
if (this.muteUntilMs > 0) {
|
|
145
|
-
const nowMsSend = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
|
|
146
|
-
if (nowMsSend < this.muteUntilMs) {
|
|
147
|
-
packet.pcm.fill(0);
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
this.muteUntilMs = 0;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
51
|
packet.release();
|
|
154
|
-
this.lastWireSeq = wireSeq;
|
|
155
52
|
};
|
|
156
53
|
const syncAudio = () => {
|
|
157
|
-
const
|
|
158
|
-
const elapsed = nowMs - this.rtpTimeRef;
|
|
54
|
+
const elapsed = Date.now() - this.rtpTimeRef;
|
|
159
55
|
if (elapsed < 0) {
|
|
160
56
|
setTimeout(syncAudio, Math.min(config_1.default.stream_latency, Math.abs(elapsed)));
|
|
161
57
|
return;
|
|
162
58
|
}
|
|
163
|
-
|
|
164
|
-
// If we're lagging behind significantly, jump forward to avoid long hitches.
|
|
165
|
-
if (config_1.default.jump_forward_enabled) {
|
|
166
|
-
const expectedTimeMs = this.rtpTimeRef + currentSeq * this.frameDurationMs;
|
|
167
|
-
const deltaMs = nowMs - expectedTimeMs;
|
|
168
|
-
if (deltaMs > config_1.default.jump_forward_threshold_ms) {
|
|
169
|
-
const jumpSeq = Math.ceil((config_1.default.jump_forward_lead_ms * config_1.default.sampling_rate) /
|
|
170
|
-
(config_1.default.frames_per_packet * 1000));
|
|
171
|
-
const newSeq = currentSeq + jumpSeq;
|
|
172
|
-
this.rtpTimeRef = nowMs - newSeq * this.frameDurationMs;
|
|
173
|
-
this.lastSeq = newSeq - 1;
|
|
174
|
-
currentSeq = newSeq;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
59
|
+
const currentSeq = Math.floor((elapsed * config_1.default.sampling_rate) / (config_1.default.frames_per_packet * 1000));
|
|
177
60
|
for (let i = this.lastSeq + 1; i <= currentSeq; i += 1) {
|
|
178
61
|
sendPacket(i);
|
|
179
62
|
}
|
|
180
63
|
this.lastSeq = currentSeq;
|
|
181
64
|
setTimeout(syncAudio, config_1.default.stream_latency);
|
|
182
65
|
};
|
|
183
|
-
|
|
184
|
-
const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
|
|
185
|
-
const delayMs = Math.max(0, this.rtpTimeRef - nowMs);
|
|
186
|
-
if (delayMs > 0) {
|
|
187
|
-
setTimeout(syncAudio, delayMs);
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
syncAudio();
|
|
191
|
-
}
|
|
66
|
+
syncAudio();
|
|
192
67
|
}
|
|
193
68
|
/**
|
|
194
69
|
* Apply latency (in audio frames) when aligning start time.
|
|
@@ -198,23 +73,12 @@ class AudioOut extends node_events_1.EventEmitter {
|
|
|
198
73
|
return;
|
|
199
74
|
}
|
|
200
75
|
this.latencyFrames = latencyFrames;
|
|
201
|
-
if (
|
|
76
|
+
if (this.startTimeMs === undefined || this.latencyApplied) {
|
|
202
77
|
return;
|
|
203
78
|
}
|
|
204
79
|
const latencyMs = (this.latencyFrames / config_1.default.sampling_rate) * 1000;
|
|
205
|
-
this.rtpTimeRef
|
|
80
|
+
this.rtpTimeRef = this.startTimeMs - latencyMs;
|
|
206
81
|
this.latencyApplied = true;
|
|
207
82
|
}
|
|
208
|
-
handleUnderrun() {
|
|
209
|
-
const nowMs = config_1.default.use_monotonic_clock ? node_perf_hooks_1.performance.now() : Date.now();
|
|
210
|
-
const currentSeq = Math.max(0, this.lastSeq);
|
|
211
|
-
const currentFrames = currentSeq * config_1.default.frames_per_packet;
|
|
212
|
-
const offsetMs = (currentFrames / config_1.default.sampling_rate) * 1000;
|
|
213
|
-
this.rtpTimeRef = nowMs - offsetMs;
|
|
214
|
-
if (config_1.default.underrun_mute_ms > 0) {
|
|
215
|
-
this.muteUntilMs = nowMs + config_1.default.underrun_mute_ms;
|
|
216
|
-
}
|
|
217
|
-
this.emit('underrun');
|
|
218
|
-
}
|
|
219
83
|
}
|
|
220
84
|
exports.default = AudioOut;
|
|
@@ -23,15 +23,14 @@ declare class BufferWithNames {
|
|
|
23
23
|
private readonly size;
|
|
24
24
|
private readonly buffer;
|
|
25
25
|
constructor(size: number);
|
|
26
|
-
add(name: string
|
|
27
|
-
getLatestNamed(name: string
|
|
26
|
+
add(name: string, item: any): void;
|
|
27
|
+
getLatestNamed(name: string): any;
|
|
28
28
|
}
|
|
29
29
|
type AirTunesDeviceInstance = EventEmitter & {
|
|
30
30
|
audioPacketHistory: BufferWithNames | null;
|
|
31
31
|
udpServers: UDPServers;
|
|
32
32
|
audioOut: EventEmitter;
|
|
33
33
|
type: string;
|
|
34
|
-
deviceMagic: number;
|
|
35
34
|
options: AnyObject;
|
|
36
35
|
host: string;
|
|
37
36
|
port: number;
|
|
@@ -64,7 +63,6 @@ type AirTunesDeviceInstance = EventEmitter & {
|
|
|
64
63
|
doHandshake: () => void;
|
|
65
64
|
relayAudio: () => void;
|
|
66
65
|
cleanup: () => void;
|
|
67
|
-
onUnderrun?: () => void;
|
|
68
66
|
};
|
|
69
67
|
/**
|
|
70
68
|
* Construct a RAOP/AirPlay device handler.
|
|
@@ -108,12 +108,10 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
108
108
|
if (!host) {
|
|
109
109
|
throw new Error('host is mandatory');
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
this.audioPacketHistory = new BufferWithNames(Number.isFinite(resendBufferSize) && resendBufferSize > 0 ? resendBufferSize : 1000);
|
|
111
|
+
this.audioPacketHistory = new BufferWithNames(100);
|
|
113
112
|
this.udpServers = new udpServers_1.default();
|
|
114
113
|
this.audioOut = audioOut;
|
|
115
114
|
this.type = 'airtunes';
|
|
116
|
-
this.deviceMagic = Number(options?.deviceMagic ?? config_1.default.device_magic);
|
|
117
115
|
this.options = options;
|
|
118
116
|
this.log = options?.log;
|
|
119
117
|
this.logLine = (...args) => logLine(this, ...args);
|
|
@@ -133,7 +131,6 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
133
131
|
this.airplay2 = options?.airplay2 ?? false;
|
|
134
132
|
this.txt = Array.isArray(txt) ? txt : txt ? [String(txt)] : [];
|
|
135
133
|
this.borkedshp = false;
|
|
136
|
-
this.transient = false;
|
|
137
134
|
if (this.airplay2 && this.mode === 0) {
|
|
138
135
|
this.mode = 2;
|
|
139
136
|
}
|
|
@@ -156,8 +153,7 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
156
153
|
}
|
|
157
154
|
this.needPassword = false;
|
|
158
155
|
this.needPin = false;
|
|
159
|
-
|
|
160
|
-
this.transient = typeof transientOption === 'boolean' ? transientOption : false;
|
|
156
|
+
this.transient = false;
|
|
161
157
|
let d = this.txt.filter((u) => String(u).startsWith('features='));
|
|
162
158
|
if (d.length === 0)
|
|
163
159
|
d = this.txt.filter((u) => String(u).startsWith('ft='));
|
|
@@ -167,7 +163,7 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
167
163
|
...(features_set.length > 1 ? parseInt(features_set[1], 10).toString(2).split('') : []),
|
|
168
164
|
];
|
|
169
165
|
if (this.features.length > 0) {
|
|
170
|
-
this.transient = this.features[this.features.length - 1 - 48]
|
|
166
|
+
this.transient = (this.features[this.features.length - 1 - 48] == '1');
|
|
171
167
|
}
|
|
172
168
|
if (this.statusflags.length) {
|
|
173
169
|
let PasswordRequired = (this.statusflags[this.statusflags.length - 1 - 7] == '1');
|
|
@@ -228,14 +224,6 @@ function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') {
|
|
|
228
224
|
this.audioCallback = null;
|
|
229
225
|
this.encoder = [];
|
|
230
226
|
this.credentials = null;
|
|
231
|
-
this.onUnderrun = () => {
|
|
232
|
-
this.encoder = [];
|
|
233
|
-
this.logLine?.('underrun_encoder_reset');
|
|
234
|
-
if (this.credentials) {
|
|
235
|
-
this.credentials.encryptCount = 0;
|
|
236
|
-
this.credentials.decryptCount = 0;
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
227
|
// this.func = `
|
|
240
228
|
// const {Worker, isMainThread, parentPort, workerData} = require('node:worker_threads');
|
|
241
229
|
// var { WebSocketServer } = require('ws');
|
|
@@ -309,7 +297,8 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
309
297
|
this.rtsp.on('ready', () => {
|
|
310
298
|
this.status = 'playing';
|
|
311
299
|
this.emit('status', 'playing');
|
|
312
|
-
this.
|
|
300
|
+
if (this.airplay2)
|
|
301
|
+
this.relayAudio();
|
|
313
302
|
});
|
|
314
303
|
this.rtsp.on('need_password', () => {
|
|
315
304
|
this.emit('status', 'need_password');
|
|
@@ -337,17 +326,10 @@ AirTunesDevice.prototype.doHandshake = function () {
|
|
|
337
326
|
this.rtsp.startHandshake(this.udpServers, this.host, this.port);
|
|
338
327
|
};
|
|
339
328
|
AirTunesDevice.prototype.relayAudio = function () {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
329
|
+
this.status = 'ready';
|
|
330
|
+
this.emit('status', 'ready');
|
|
343
331
|
this.audioCallback = (packet) => {
|
|
344
|
-
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec
|
|
345
|
-
try {
|
|
346
|
-
this.audioPacketHistory?.add(packet.seq, airTunes);
|
|
347
|
-
}
|
|
348
|
-
catch {
|
|
349
|
-
/* ignore history errors */
|
|
350
|
-
}
|
|
332
|
+
const airTunes = makeAirTunesPacket(packet, this.encoder, this.requireEncryption, this.alacEncoding, this.credentials, this.inputCodec);
|
|
351
333
|
// if (self.credentials) {
|
|
352
334
|
// airTunes = self.credentials.encrypt(airTunes)
|
|
353
335
|
// }
|
|
@@ -383,24 +365,10 @@ AirTunesDevice.prototype.relayAudio = function () {
|
|
|
383
365
|
// self.sendAirTunesPacket(airTunes);}}
|
|
384
366
|
// catch (_){}
|
|
385
367
|
// });
|
|
386
|
-
this.udpServers.on('resendRequested', (missedSeq, count) => {
|
|
387
|
-
for (let i = 0; i < count; i += 1) {
|
|
388
|
-
const seq = missedSeq + i;
|
|
389
|
-
try {
|
|
390
|
-
const pkt = this.audioPacketHistory?.getLatestNamed(seq);
|
|
391
|
-
if (pkt && this.audioSocket) {
|
|
392
|
-
this.audioSocket.send(pkt, 0, pkt.length, this.serverPort, this.host);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
// ignore resend errors
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
368
|
this.audioOut.on('packet', this.audioCallback);
|
|
401
369
|
};
|
|
402
|
-
AirTunesDevice.prototype.onSyncNeeded = function (seq
|
|
403
|
-
this.udpServers.sendControlSync(seq, this
|
|
370
|
+
AirTunesDevice.prototype.onSyncNeeded = function (seq) {
|
|
371
|
+
this.udpServers.sendControlSync(seq, this);
|
|
404
372
|
//if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp);
|
|
405
373
|
};
|
|
406
374
|
AirTunesDevice.prototype.cleanup = function () {
|
|
@@ -413,7 +381,6 @@ AirTunesDevice.prototype.cleanup = function () {
|
|
|
413
381
|
this.audioOut.removeListener('packet', this.audioCallback);
|
|
414
382
|
this.audioCallback = null;
|
|
415
383
|
}
|
|
416
|
-
this.encoder = [];
|
|
417
384
|
this.udpServers.close();
|
|
418
385
|
this.removeAllListeners();
|
|
419
386
|
this.rtsp = null;
|
|
@@ -423,11 +390,8 @@ AirTunesDevice.prototype.reportStatus = function () {
|
|
|
423
390
|
};
|
|
424
391
|
AirTunesDevice.prototype.stop = function (cb) {
|
|
425
392
|
try {
|
|
426
|
-
if (!this.rtsp)
|
|
427
|
-
if (cb)
|
|
428
|
-
cb();
|
|
393
|
+
if (!this.rtsp)
|
|
429
394
|
return;
|
|
430
|
-
}
|
|
431
395
|
this.rtsp.once('end', function () {
|
|
432
396
|
if (cb)
|
|
433
397
|
cb();
|
|
@@ -462,8 +426,11 @@ AirTunesDevice.prototype.setPasscode = function (password) {
|
|
|
462
426
|
return;
|
|
463
427
|
this.rtsp.setPasscode(password);
|
|
464
428
|
};
|
|
429
|
+
AirTunesDevice.prototype.requireEncryption = function () {
|
|
430
|
+
return Boolean(this.requireEncryption);
|
|
431
|
+
};
|
|
465
432
|
exports.default = AirTunesDevice;
|
|
466
|
-
function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = true, credentials = null, inputCodec = 'pcm'
|
|
433
|
+
function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = true, credentials = null, inputCodec = 'pcm') {
|
|
467
434
|
const useAlacInput = inputCodec === 'alac';
|
|
468
435
|
var alac = useAlacInput
|
|
469
436
|
? packet.pcm
|
|
@@ -471,7 +438,7 @@ function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = t
|
|
|
471
438
|
? (0, alac_1.encodePcmToAlac)(packet.pcm)
|
|
472
439
|
: pcmParse(packet.pcm);
|
|
473
440
|
var airTunes = Buffer.alloc(alac.length + RTP_HEADER_SIZE);
|
|
474
|
-
var header = makeRTPHeader(packet
|
|
441
|
+
var header = makeRTPHeader(packet);
|
|
475
442
|
if (requireEncryption) {
|
|
476
443
|
alac = encryptAES(alac, alac.length);
|
|
477
444
|
}
|
|
@@ -521,7 +488,7 @@ function encryptAES(alacData, alacSize) {
|
|
|
521
488
|
}
|
|
522
489
|
return Buffer.concat([result, alacData.slice(end_of_encoded_data)]);
|
|
523
490
|
}
|
|
524
|
-
function makeRTPHeader(packet
|
|
491
|
+
function makeRTPHeader(packet) {
|
|
525
492
|
var header = Buffer.alloc(RTP_HEADER_SIZE);
|
|
526
493
|
if (packet.seq === 0)
|
|
527
494
|
header.writeUInt16BE(0x80e0, 0);
|
|
@@ -529,6 +496,6 @@ function makeRTPHeader(packet, deviceMagic) {
|
|
|
529
496
|
header.writeUInt16BE(0x8060, 0);
|
|
530
497
|
header.writeUInt16BE(nu.low16(packet.seq), 2);
|
|
531
498
|
header.writeUInt32BE(packet.timestamp, 4);
|
|
532
|
-
header.writeUInt32BE(
|
|
499
|
+
header.writeUInt32BE(config_1.default.device_magic, 8);
|
|
533
500
|
return header;
|
|
534
501
|
}
|
package/dist/core/devices.js
CHANGED
|
@@ -20,11 +20,11 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
20
20
|
}
|
|
21
21
|
/** Wire device sync events from AudioOut into individual devices. */
|
|
22
22
|
init() {
|
|
23
|
-
this.audioOut.on('need_sync', (
|
|
23
|
+
this.audioOut.on('need_sync', (seq) => {
|
|
24
24
|
this.forEach((dev) => {
|
|
25
25
|
try {
|
|
26
26
|
if (dev.onSyncNeeded && dev.controlPort) {
|
|
27
|
-
dev.onSyncNeeded(
|
|
27
|
+
dev.onSyncNeeded(seq);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
catch {
|
|
@@ -32,18 +32,6 @@ class Devices extends node_events_1.EventEmitter {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
-
this.audioOut.on('underrun', () => {
|
|
36
|
-
this.forEach((dev) => {
|
|
37
|
-
try {
|
|
38
|
-
if (dev.onUnderrun) {
|
|
39
|
-
dev.onUnderrun();
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* ignore */
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
35
|
}
|
|
48
36
|
/** Iterate over live devices. */
|
|
49
37
|
forEach(it) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Duplex } from 'node:stream';
|
|
2
2
|
import Devices from './devices';
|
|
3
3
|
import { type AirplayConfig } from '../utils/config';
|
|
4
|
-
import { type NtpTimestampInput } from '../utils/ntp';
|
|
5
4
|
/**
|
|
6
5
|
* High-level RAOP/AirPlay sender that wires together devices, buffering, and output.
|
|
7
6
|
* Acts as a Duplex stream: write PCM/ALAC chunks, listen to status events.
|
|
@@ -9,20 +8,14 @@ import { type NtpTimestampInput } from '../utils/ntp';
|
|
|
9
8
|
declare class AirTunes extends Duplex {
|
|
10
9
|
readonly devices: Devices;
|
|
11
10
|
private readonly circularBuffer;
|
|
12
|
-
private readonly deviceMagic;
|
|
13
11
|
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*/
|
|
12
|
+
* @param options.packetSize Override packet size; defaults to config.
|
|
13
|
+
* @param options.startTimeMs Optional unix ms to align playback start.
|
|
14
|
+
*/
|
|
18
15
|
constructor(options?: {
|
|
19
16
|
packetSize?: number;
|
|
20
17
|
startTimeMs?: number;
|
|
21
|
-
startTimeNtp?: NtpTimestampInput;
|
|
22
18
|
config?: Partial<AirplayConfig>;
|
|
23
|
-
deviceMagic?: number;
|
|
24
|
-
resendBufferSize?: number;
|
|
25
|
-
underrunMuteMs?: number;
|
|
26
19
|
});
|
|
27
20
|
/** Register an AirTunes (RAOP) device and start streaming to it. */
|
|
28
21
|
add(host: string, options: Record<string, unknown>, mode?: number, txt?: string[] | string): any;
|
package/dist/core/index.js
CHANGED
|
@@ -40,8 +40,6 @@ const node_stream_1 = require("node:stream");
|
|
|
40
40
|
const devices_1 = __importDefault(require("./devices"));
|
|
41
41
|
const config_1 = __importStar(require("../utils/config"));
|
|
42
42
|
const circularBuffer_1 = __importDefault(require("../utils/circularBuffer"));
|
|
43
|
-
const ntp_1 = require("../utils/ntp");
|
|
44
|
-
const numUtil_1 = require("../utils/numUtil");
|
|
45
43
|
const audioOut_1 = __importDefault(require("./audioOut"));
|
|
46
44
|
/**
|
|
47
45
|
* High-level RAOP/AirPlay sender that wires together devices, buffering, and output.
|
|
@@ -50,33 +48,27 @@ const audioOut_1 = __importDefault(require("./audioOut"));
|
|
|
50
48
|
class AirTunes extends node_stream_1.Duplex {
|
|
51
49
|
devices;
|
|
52
50
|
circularBuffer;
|
|
53
|
-
deviceMagic;
|
|
54
51
|
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*/
|
|
52
|
+
* @param options.packetSize Override packet size; defaults to config.
|
|
53
|
+
* @param options.startTimeMs Optional unix ms to align playback start.
|
|
54
|
+
*/
|
|
59
55
|
constructor(options = {}) {
|
|
60
56
|
super({ readableObjectMode: false, writableObjectMode: false });
|
|
61
57
|
if (options.config) {
|
|
62
58
|
(0, config_1.applyConfig)(options.config);
|
|
63
59
|
}
|
|
64
|
-
// Randomize per-session device magic (SSRC equivalent) to align with reference behavior.
|
|
65
|
-
const deviceMagic = typeof options.deviceMagic === 'number' ? options.deviceMagic : (0, numUtil_1.randomInt)(9);
|
|
66
60
|
const audioOut = new audioOut_1.default();
|
|
67
|
-
this.deviceMagic = deviceMagic;
|
|
68
61
|
this.devices = new devices_1.default(audioOut);
|
|
69
62
|
this.devices.init();
|
|
70
63
|
this.devices.on('status', (key, status, desc) => {
|
|
71
64
|
this.emit('device', key, status, desc);
|
|
72
65
|
});
|
|
73
66
|
const packetSize = options.packetSize ?? config_1.default.packet_size;
|
|
74
|
-
const startTimeNtp = options.startTimeNtp ? (0, ntp_1.toNtpTimestamp)(options.startTimeNtp) : undefined;
|
|
75
67
|
this.circularBuffer = new circularBuffer_1.default(config_1.default.packets_in_buffer, packetSize);
|
|
76
68
|
this.circularBuffer.on('status', (status) => {
|
|
77
69
|
this.emit('buffer', status);
|
|
78
70
|
});
|
|
79
|
-
audioOut.init(this.devices, this.circularBuffer, options.startTimeMs
|
|
71
|
+
audioOut.init(this.devices, this.circularBuffer, options.startTimeMs);
|
|
80
72
|
audioOut.on('metrics', (metrics) => {
|
|
81
73
|
this.emit('metrics', metrics);
|
|
82
74
|
});
|
|
@@ -89,7 +81,7 @@ class AirTunes extends node_stream_1.Duplex {
|
|
|
89
81
|
}
|
|
90
82
|
/** Register an AirTunes (RAOP) device and start streaming to it. */
|
|
91
83
|
add(host, options, mode = 0, txt = '') {
|
|
92
|
-
return this.devices.add('airtunes', host,
|
|
84
|
+
return this.devices.add('airtunes', host, options, mode, txt);
|
|
93
85
|
}
|
|
94
86
|
/** Register a CoreAudio output (legacy shim). */
|
|
95
87
|
addCoreAudio(options) {
|