@camera.ui/camera-ui-homekit 0.0.16 → 0.0.18
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/camera/accessory.js +4 -13
- package/dist/camera/accessory.js.map +1 -1
- package/dist/camera/recordingDelegate.d.ts +2 -2
- package/dist/camera/recordingDelegate.js +10 -9
- package/dist/camera/recordingDelegate.js.map +1 -1
- package/dist/camera/services.d.ts +2 -2
- package/dist/camera/services.js.map +1 -1
- package/dist/camera/sessionWrapper.d.ts +14 -11
- package/dist/camera/sessionWrapper.js +264 -221
- package/dist/camera/sessionWrapper.js.map +1 -1
- package/dist/camera/streamingDelegate.d.ts +2 -2
- package/dist/camera/streamingDelegate.js +1 -2
- package/dist/camera/streamingDelegate.js.map +1 -1
- package/dist/camera/streamingServer.d.ts +2 -2
- package/dist/camera/streamingServer.js.map +1 -1
- package/dist/constants.d.ts +1 -4
- package/dist/constants.js +6 -2
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +1 -4
- package/dist/utils/processor.d.ts +45 -0
- package/dist/utils/processor.js +133 -0
- package/dist/utils/processor.js.map +1 -0
- package/dist/utils/return-audio-transcoder.d.ts +2 -3
- package/dist/utils/return-audio-transcoder.js +1 -3
- package/dist/utils/return-audio-transcoder.js.map +1 -1
- package/dist/utils/srtp.js +1 -1
- package/dist/utils/srtp.js.map +1 -1
- package/dist/utils/utils.d.ts +3 -1
- package/dist/utils/utils.js +5 -13
- package/dist/utils/utils.js.map +1 -1
- package/package.json +9 -8
- package/dist/utils/ffmpeg-process.d.ts +0 -23
- package/dist/utils/ffmpeg-process.js +0 -72
- package/dist/utils/ffmpeg-process.js.map +0 -1
- package/dist/utils/ffmpeg.d.ts +0 -2
- package/dist/utils/ffmpeg.js +0 -17
- package/dist/utils/ffmpeg.js.map +0 -1
- package/dist/utils/mp4.d.ts +0 -10
- package/dist/utils/mp4.js +0 -53
- package/dist/utils/mp4.js.map +0 -1
- package/dist/utils/ports.d.ts +0 -7
- package/dist/utils/ports.js +0 -43
- package/dist/utils/ports.js.map +0 -1
- package/dist/utils/rtp-splitter.d.ts +0 -34
- package/dist/utils/rtp-splitter.js +0 -76
- package/dist/utils/rtp-splitter.js.map +0 -1
- package/dist/utils/rtp.d.ts +0 -3
- package/dist/utils/rtp.js +0 -16
- package/dist/utils/rtp.js.map +0 -1
|
@@ -1,29 +1,15 @@
|
|
|
1
|
+
import { timeoutPromise } from '@camera.ui/common';
|
|
2
|
+
import { RtpSplitter } from '@camera.ui/common/cameraUtils';
|
|
1
3
|
import { CameraController } from 'hap-nodejs';
|
|
2
4
|
import { spawn } from 'node:child_process';
|
|
3
5
|
import { isIPv6 } from 'node:net';
|
|
4
6
|
import { networkInterfaces } from 'node:os';
|
|
5
|
-
import {
|
|
7
|
+
import { interval, merge, of, Subject } from 'rxjs';
|
|
6
8
|
import { debounceTime, delay, take } from 'rxjs/operators';
|
|
7
9
|
import { RtcpSenderInfo, RtcpSrPacket, RtpPacket, SrtcpSession, SrtpSession } from 'werift';
|
|
8
|
-
import {
|
|
10
|
+
import { AudioProcessor, getSessionConfig, VideoProcessor } from '../utils/processor.js';
|
|
9
11
|
import { ReturnAudioTranscoder } from '../utils/return-audio-transcoder.js';
|
|
10
|
-
import { RtpSplitter } from '../utils/rtp-splitter.js';
|
|
11
12
|
import { generateSrtpOptions } from '../utils/srtp.js';
|
|
12
|
-
import { timeoutPromise } from '../utils/utils.js';
|
|
13
|
-
function getSessionConfig(srtpOptions) {
|
|
14
|
-
return {
|
|
15
|
-
keys: {
|
|
16
|
-
localMasterKey: srtpOptions.srtpKey,
|
|
17
|
-
localMasterSalt: srtpOptions.srtpSalt,
|
|
18
|
-
remoteMasterKey: srtpOptions.srtpKey,
|
|
19
|
-
remoteMasterSalt: srtpOptions.srtpSalt,
|
|
20
|
-
},
|
|
21
|
-
profile: 1,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
function getDurationSeconds(start) {
|
|
25
|
-
return (Date.now() - start) / 1000;
|
|
26
|
-
}
|
|
27
13
|
export class StreamingSessionWrapper {
|
|
28
14
|
start;
|
|
29
15
|
audioSsrc = CameraController.generateSynchronisationSource();
|
|
@@ -35,53 +21,21 @@ export class StreamingSessionWrapper {
|
|
|
35
21
|
cameraAccessory;
|
|
36
22
|
cameraDevice;
|
|
37
23
|
streamingSession;
|
|
38
|
-
ffmpegProcess;
|
|
39
24
|
prepareStreamRequest;
|
|
25
|
+
ffmpegProcess;
|
|
40
26
|
logger;
|
|
41
27
|
repacketizeAudioSplitter = new RtpSplitter();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return firstValueFrom(this.rtcpSubject);
|
|
45
|
-
}
|
|
46
|
-
constructor(cameraAccessory, cameraDevice, streamingSession, prepareStreamRequest, start, logger) {
|
|
28
|
+
packetReceivedSubject = new Subject();
|
|
29
|
+
constructor(cameraAccessory, cameraDevice, prepareStreamRequest, start, logger) {
|
|
47
30
|
this.cameraAccessory = cameraAccessory;
|
|
48
31
|
this.cameraDevice = cameraDevice;
|
|
49
|
-
this.streamingSession =
|
|
32
|
+
this.streamingSession = this.cameraDevice.streamSource.createSession();
|
|
50
33
|
this.prepareStreamRequest = prepareStreamRequest;
|
|
51
34
|
this.start = start;
|
|
52
35
|
this.logger = logger;
|
|
53
36
|
}
|
|
54
37
|
async prepare() {
|
|
55
|
-
const { sessionID, targetAddress, addressVersion } = this.
|
|
56
|
-
let { sourceAddress } = this.prepareStreamRequest;
|
|
57
|
-
const socketType = addressVersion === 'ipv6' ? 'udp6' : 'udp4';
|
|
58
|
-
if (socketType === 'udp4' && sourceAddress.startsWith('::ffff:')) {
|
|
59
|
-
sourceAddress = sourceAddress.replace('::ffff:', '');
|
|
60
|
-
}
|
|
61
|
-
const serverAddresses = await this.cameraAccessory.api.coreManager.getServerAddresses();
|
|
62
|
-
const found = serverAddresses.find((address) => address.includes(sourceAddress));
|
|
63
|
-
if (!found && serverAddresses.length) {
|
|
64
|
-
this.logger.debug(this.cameraDevice.name, `Source address ${sourceAddress} not found in server addresses`);
|
|
65
|
-
const infos = Object.values(networkInterfaces())
|
|
66
|
-
.flat()
|
|
67
|
-
.map((i) => i?.address);
|
|
68
|
-
const targetAddresses = serverAddresses.filter((address) => {
|
|
69
|
-
if (socketType === 'udp4') {
|
|
70
|
-
return !isIPv6(address);
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
return isIPv6(address);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
const targetAddressFound = infos.find((address) => targetAddresses.includes(address));
|
|
77
|
-
if (targetAddressFound) {
|
|
78
|
-
this.logger.debug(this.cameraDevice.name, `Using target address ${targetAddressFound}`);
|
|
79
|
-
sourceAddress = targetAddressFound;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
else if (found) {
|
|
83
|
-
this.logger.debug(this.cameraDevice.name, `Using source address ${sourceAddress}`);
|
|
84
|
-
}
|
|
38
|
+
const { socketType, sessionID, sourceAddress, targetAddress, addressVersion } = await this.setupAddress();
|
|
85
39
|
this.logger.debug(this.cameraDevice.name, 'Preparing stream:', { sessionID, sourceAddress, targetAddress, addressVersion });
|
|
86
40
|
await this.videoSplitter.prepare(socketType, sourceAddress);
|
|
87
41
|
await this.audioSplitter.prepare(socketType, sourceAddress);
|
|
@@ -89,31 +43,33 @@ export class StreamingSessionWrapper {
|
|
|
89
43
|
if (!this.videoSplitter.port || !this.audioSplitter.port || !this.repacketizeAudioSplitter.port) {
|
|
90
44
|
throw new Error('Failed to prepare stream');
|
|
91
45
|
}
|
|
92
|
-
// used to encrypt rtcp to HomeKit for keepalive
|
|
93
46
|
const videoSrtcpSession = new SrtcpSession(getSessionConfig(this.videoSrtp));
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
47
|
+
let firstRtcp = false;
|
|
48
|
+
const logFirstRtcp = () => {
|
|
49
|
+
this.logger.debug(this.cameraDevice.name, 'Received RTCP packet from HomeKit');
|
|
50
|
+
};
|
|
97
51
|
this.videoSplitter.addMessageHandler(() => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
52
|
+
if (!firstRtcp) {
|
|
53
|
+
firstRtcp = true;
|
|
54
|
+
logFirstRtcp();
|
|
55
|
+
}
|
|
56
|
+
this.packetReceivedSubject.next();
|
|
102
57
|
return null;
|
|
103
58
|
});
|
|
104
59
|
this.audioSplitter.addMessageHandler(() => {
|
|
105
|
-
|
|
106
|
-
|
|
60
|
+
if (!firstRtcp) {
|
|
61
|
+
firstRtcp = true;
|
|
62
|
+
logFirstRtcp();
|
|
63
|
+
}
|
|
64
|
+
this.packetReceivedSubject.next();
|
|
107
65
|
return null;
|
|
108
66
|
});
|
|
109
|
-
this.streamingSession.addSubscriptions(merge(of(
|
|
67
|
+
this.streamingSession.addSubscriptions(merge(of(null).pipe(delay(15000)), this.packetReceivedSubject.asObservable())
|
|
110
68
|
.pipe(debounceTime(5000))
|
|
111
69
|
.subscribe(() => {
|
|
112
|
-
this.logger.log(this.cameraDevice.name, `Live stream appears to be inactive. (${getDurationSeconds(
|
|
70
|
+
this.logger.log(this.cameraDevice.name, `Live stream appears to be inactive. (${this.getDurationSeconds()}s)`);
|
|
113
71
|
this.streamingSession.stop();
|
|
114
72
|
}));
|
|
115
|
-
// Periodically send a blank RTCP packet to the HomeKit video port
|
|
116
|
-
// Without this, HomeKit assumes the stream is dead after 30 second and sends a stop request
|
|
117
73
|
this.streamingSession.addSubscriptions(interval(500).subscribe(() => {
|
|
118
74
|
const senderInfo = new RtcpSenderInfo({
|
|
119
75
|
ntpTimestamp: BigInt(0),
|
|
@@ -125,8 +81,8 @@ export class StreamingSessionWrapper {
|
|
|
125
81
|
ssrc: this.videoSsrc,
|
|
126
82
|
senderInfo: senderInfo,
|
|
127
83
|
});
|
|
128
|
-
const
|
|
129
|
-
this.videoSplitter.send(
|
|
84
|
+
const encryptedPacket = videoSrtcpSession.encrypt(senderReport.serialize());
|
|
85
|
+
this.videoSplitter.send(encryptedPacket, {
|
|
130
86
|
port: this.prepareStreamRequest.video.port,
|
|
131
87
|
address: this.prepareStreamRequest.targetAddress,
|
|
132
88
|
});
|
|
@@ -135,13 +91,13 @@ export class StreamingSessionWrapper {
|
|
|
135
91
|
async activate(startStreamRequest) {
|
|
136
92
|
this.logger.debug(this.cameraDevice.name, 'Starting stream:', startStreamRequest);
|
|
137
93
|
if (!this.isLowBandwidth(startStreamRequest)) {
|
|
138
|
-
this.run(startStreamRequest);
|
|
94
|
+
await this.run(startStreamRequest);
|
|
139
95
|
return;
|
|
140
96
|
}
|
|
141
97
|
try {
|
|
142
98
|
this.logger.debug(this.cameraDevice.name, 'Low bandwidth detected, waiting for initial RTCP');
|
|
143
|
-
await timeoutPromise(5000, this.
|
|
144
|
-
this.run(startStreamRequest);
|
|
99
|
+
await timeoutPromise(5000, this.packetReceivedSubject.asObservable().pipe(take(1)).toPromise());
|
|
100
|
+
await this.run(startStreamRequest);
|
|
145
101
|
}
|
|
146
102
|
catch {
|
|
147
103
|
this.logger.error(this.cameraDevice.name, 'Failed to receive initial RTCP packet');
|
|
@@ -150,7 +106,6 @@ export class StreamingSessionWrapper {
|
|
|
150
106
|
}
|
|
151
107
|
stop() {
|
|
152
108
|
this.logger.debug(this.cameraDevice.name, 'Stopping stream');
|
|
153
|
-
this.rtcpSubject = new ReplaySubject(1);
|
|
154
109
|
this.ffmpegProcess?.kill('SIGKILL');
|
|
155
110
|
this.ffmpegProcess = undefined;
|
|
156
111
|
this.audioSplitter.close();
|
|
@@ -159,18 +114,71 @@ export class StreamingSessionWrapper {
|
|
|
159
114
|
this.streamingSession.stop();
|
|
160
115
|
}
|
|
161
116
|
async run(startStreamRequest) {
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
117
|
+
const probeStream = await this.cameraDevice.streamSource.probeStream();
|
|
118
|
+
const audioStream = probeStream?.audio.find((a) => a.direction === 'sendonly');
|
|
119
|
+
const videoStream = probeStream?.video;
|
|
120
|
+
const talkbackStream = probeStream?.audio.find((a) => a.direction === 'recvonly');
|
|
121
|
+
const mpegtsPrebufferingState = await this.cameraDevice.streamSource.getPrebufferingState('mpegts');
|
|
122
|
+
const transcodeVideoStream = this.cameraAccessory.cameraStorage.values.transcodeStreaming;
|
|
123
|
+
const useRtsp = transcodeVideoStream || mpegtsPrebufferingState?.url || videoStream?.codec !== 'H264';
|
|
124
|
+
if (audioStream) {
|
|
125
|
+
this.listenForAudioPackets(startStreamRequest);
|
|
126
|
+
}
|
|
127
|
+
if (useRtsp) {
|
|
128
|
+
this.listenForVideoRtspPackets(startStreamRequest);
|
|
129
|
+
await this.startRtspSession(startStreamRequest);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.listenForVideoPackets(startStreamRequest);
|
|
133
|
+
}
|
|
134
|
+
if (audioStream) {
|
|
135
|
+
await this.startAudioSession(startStreamRequest);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.logger.debug(this.cameraDevice.name, 'No audio stream detected, skipping audio transcoding and talkback');
|
|
139
|
+
}
|
|
140
|
+
if (talkbackStream) {
|
|
141
|
+
await this.createTwoWayAudioTranscoder(startStreamRequest, talkbackStream);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
this.logger.debug(this.cameraDevice.name, 'No talkback stream detected, skipping two-way audio');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async startAudioSession(startStreamRequest) {
|
|
148
|
+
const audioMtu = 400;
|
|
149
|
+
const ffmpegPath = await this.cameraAccessory.api.coreManager.getFFmpegPath();
|
|
150
|
+
const audioArgs = [];
|
|
151
|
+
if (startStreamRequest.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */) {
|
|
152
|
+
audioArgs.push(...this.opusTranscodeArgs(startStreamRequest));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
audioArgs.push(...this.aacTranscodeArgs(startStreamRequest));
|
|
156
|
+
}
|
|
157
|
+
await this.streamingSession.startTranscoding({
|
|
158
|
+
ffmpegPath,
|
|
159
|
+
input: ['-vn', '-sn', '-dn'],
|
|
160
|
+
audio: [...audioArgs, '-f', 'rtp', `rtp://${this.repacketizeAudioSplitter.address}:${this.repacketizeAudioSplitter.port}?pkt_size=${audioMtu}`],
|
|
161
|
+
output: [],
|
|
162
|
+
logger: {
|
|
163
|
+
info: (...args) => this.logger.trace(this.cameraDevice.name, 'Audio:', ...args),
|
|
164
|
+
error: (...args) => this.logger.error(this.cameraDevice.name, 'Audio:', ...args),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
166
167
|
}
|
|
167
168
|
async startRtspSession(startStreamRequest) {
|
|
168
169
|
const audioMtu = startStreamRequest.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */ ? 400 : 3840 / startStreamRequest.audio.sample_rate;
|
|
169
170
|
const videoMtu = startStreamRequest.video.mtu;
|
|
171
|
+
const probeStream = await this.cameraDevice.streamSource.probeStream();
|
|
172
|
+
const audioStream = probeStream?.audio.find((a) => a.direction === 'sendonly');
|
|
173
|
+
const videoStream = probeStream?.video;
|
|
170
174
|
const ffmpegPath = await this.cameraAccessory.api.coreManager.getFFmpegPath();
|
|
171
175
|
const mpegtsPrebufferingState = await this.cameraDevice.streamSource.getPrebufferingState('mpegts');
|
|
172
|
-
const isPrebufferingEnabled = mpegtsPrebufferingState?.
|
|
173
|
-
const
|
|
176
|
+
const isPrebufferingEnabled = mpegtsPrebufferingState?.url;
|
|
177
|
+
const isH264 = videoStream?.codec === 'H264';
|
|
178
|
+
const transcodeVideoStream = this.cameraAccessory.cameraStorage.values.transcodeStreaming || !isH264;
|
|
179
|
+
if (!isH264) {
|
|
180
|
+
this.logger.warn(this.cameraDevice.name, 'Stream is not H264, streaming will be transcoded');
|
|
181
|
+
}
|
|
174
182
|
const ffmpegInput = [
|
|
175
183
|
'-hide_banner',
|
|
176
184
|
'-loglevel',
|
|
@@ -185,31 +193,13 @@ export class StreamingSessionWrapper {
|
|
|
185
193
|
'1024',
|
|
186
194
|
];
|
|
187
195
|
if (isPrebufferingEnabled) {
|
|
188
|
-
ffmpegInput.push('-f', 'mpegts', '-i', mpegtsPrebufferingState.url
|
|
196
|
+
ffmpegInput.push('-f', 'mpegts', '-i', mpegtsPrebufferingState.url);
|
|
189
197
|
ffmpegInput.push('-enc_time_base', '-1', '-muxdelay', '0', '-video_track_timescale', '90000');
|
|
190
198
|
}
|
|
191
199
|
else {
|
|
192
|
-
ffmpegInput.push('-avioflags', 'direct', '-rtsp_transport', 'tcp', '-i', this.cameraDevice.streamSource.urls.rtsp.default
|
|
200
|
+
ffmpegInput.push('-avioflags', 'direct', '-rtsp_transport', 'tcp', '-i', this.cameraDevice.streamSource.urls.rtsp.default);
|
|
193
201
|
}
|
|
194
202
|
ffmpegInput.push('-fps_mode', 'passthrough', '-reset_timestamps', '1');
|
|
195
|
-
const audioCodec = [
|
|
196
|
-
'-flags',
|
|
197
|
-
'+global_header',
|
|
198
|
-
'-ac',
|
|
199
|
-
`${startStreamRequest.audio.channel}`,
|
|
200
|
-
'-ar',
|
|
201
|
-
`${startStreamRequest.audio.sample_rate}k`,
|
|
202
|
-
'-b:a',
|
|
203
|
-
`${startStreamRequest.audio.max_bit_rate}k`,
|
|
204
|
-
'-bufsize',
|
|
205
|
-
`${startStreamRequest.audio.max_bit_rate * 4}k`,
|
|
206
|
-
];
|
|
207
|
-
if (startStreamRequest.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */) {
|
|
208
|
-
audioCodec.unshift('-acodec', 'libopus', '-frame_duration', startStreamRequest.audio.packet_time.toString(), '-application', 'lowdelay');
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
audioCodec.unshift('-acodec', 'libfdk_aac', '-profile:a', 'aac_eld', '-eld_sbr:a', '1', '-eld_v2', '1', '-frame_size', (startStreamRequest.audio.packet_time * startStreamRequest.audio.sample_rate).toString(), '-f', 'null');
|
|
212
|
-
}
|
|
213
203
|
const videoCodec = [];
|
|
214
204
|
if (transcodeVideoStream) {
|
|
215
205
|
const idrInterval = 4;
|
|
@@ -239,21 +229,17 @@ export class StreamingSessionWrapper {
|
|
|
239
229
|
'rtp',
|
|
240
230
|
`rtp://${this.videoSplitter.address}:${this.videoSplitter.port}?pkt_size=${videoMtu}`,
|
|
241
231
|
];
|
|
242
|
-
const audioArgs = [
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
'-sn',
|
|
252
|
-
|
|
253
|
-
'-f',
|
|
254
|
-
'rtp',
|
|
255
|
-
`rtp://${this.repacketizeAudioSplitter.address}:${this.repacketizeAudioSplitter.port}?pkt_size=${audioMtu}`,
|
|
256
|
-
];
|
|
232
|
+
const audioArgs = [];
|
|
233
|
+
if (audioStream) {
|
|
234
|
+
const audioCodecArgs = [];
|
|
235
|
+
if (startStreamRequest.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */) {
|
|
236
|
+
audioCodecArgs.push(...this.opusTranscodeArgs(startStreamRequest));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
audioCodecArgs.push(...this.aacTranscodeArgs(startStreamRequest));
|
|
240
|
+
}
|
|
241
|
+
audioArgs.push(...audioCodecArgs, '-vn', '-sn', '-dn', '-f', 'rtp', `rtp://${this.repacketizeAudioSplitter.address}:${this.repacketizeAudioSplitter.port}?pkt_size=${audioMtu}`);
|
|
242
|
+
}
|
|
257
243
|
const ffmpegOutput = [];
|
|
258
244
|
const ffmpegArgs = [...ffmpegInput, ...videoArgs, ...audioArgs, ...ffmpegOutput];
|
|
259
245
|
this.logger.debug(this.cameraDevice.name, 'Starting RTSP session with ffmpeg', ffmpegPath, ffmpegArgs.join(' '));
|
|
@@ -276,71 +262,85 @@ export class StreamingSessionWrapper {
|
|
|
276
262
|
}
|
|
277
263
|
});
|
|
278
264
|
}
|
|
279
|
-
async createTwoWayAudioTranscoder(startStreamRequest) {
|
|
280
|
-
const twoWayAudio = await this.cameraAccessory.cameraStorage.getValue('twoWayAudio', true);
|
|
281
|
-
let returnAudioTranscodedSplitter;
|
|
282
|
-
let returnAudioTranscoder;
|
|
265
|
+
async createTwoWayAudioTranscoder(startStreamRequest, talkbackStream) {
|
|
283
266
|
const returnAudioCodecs = [];
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
267
|
+
const returnAudioCodec = talkbackStream.ffmpegCodec;
|
|
268
|
+
switch (returnAudioCodec) {
|
|
269
|
+
case 'aac':
|
|
270
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '16k', '-b:a', '32k');
|
|
271
|
+
break;
|
|
272
|
+
case 'libopus':
|
|
273
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '2', '-ar', '24k', '-b:a', '24k', '-application', 'lowdelay');
|
|
274
|
+
break;
|
|
275
|
+
case 'pcm_mulaw':
|
|
276
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
277
|
+
break;
|
|
278
|
+
case 'pcm_alaw':
|
|
279
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
280
|
+
break;
|
|
281
|
+
case 'pcm_s16le':
|
|
282
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
283
|
+
break;
|
|
284
|
+
case 'pcm_s16be':
|
|
285
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
286
|
+
break;
|
|
287
|
+
case 'g722':
|
|
288
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
289
|
+
break;
|
|
290
|
+
case 'mp3':
|
|
291
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
292
|
+
break;
|
|
293
|
+
case 'flac':
|
|
294
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
returnAudioCodecs.push('-acodec', returnAudioCodec, '-ac', '1', '-ar', '8k');
|
|
298
|
+
}
|
|
299
|
+
const returnAudioTranscodedSplitter = new RtpSplitter();
|
|
300
|
+
await returnAudioTranscodedSplitter.prepare('udp4', '127.0.0.1', ({ message }) => {
|
|
301
|
+
try {
|
|
302
|
+
const rtp = RtpPacket.deSerialize(message);
|
|
303
|
+
this.streamingSession.sendAudioPacket(rtp);
|
|
305
304
|
}
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
catch {
|
|
306
|
+
// Ignore deserialization errors
|
|
308
307
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
});
|
|
333
|
-
this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
334
|
-
returnAudioTranscoder?.stop();
|
|
335
|
-
returnAudioTranscodedSplitter?.close();
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
await returnAudioTranscoder?.start();
|
|
308
|
+
return null;
|
|
309
|
+
});
|
|
310
|
+
const ffmpegPath = await this.cameraAccessory.api.coreManager.getFFmpegPath();
|
|
311
|
+
const returnAudioTranscoder = new ReturnAudioTranscoder({
|
|
312
|
+
prepareStreamRequest: this.prepareStreamRequest,
|
|
313
|
+
startStreamRequest,
|
|
314
|
+
incomingAudioOptions: {
|
|
315
|
+
ssrc: this.audioSsrc,
|
|
316
|
+
rtcpPort: 0,
|
|
317
|
+
},
|
|
318
|
+
outputArgs: [...returnAudioCodecs, '-flags', '+global_header', '-f', 'rtp', `rtp://${returnAudioTranscodedSplitter.address}:${returnAudioTranscodedSplitter.port}`],
|
|
319
|
+
ffmpegPath,
|
|
320
|
+
returnAudioSplitter: this.audioSplitter,
|
|
321
|
+
logger: {
|
|
322
|
+
info: (...args) => this.logger.trace(this.cameraDevice.name, 'Return Audio:', ...args),
|
|
323
|
+
error: (...args) => this.logger.error(this.cameraDevice.name, 'Return Audio:', ...args),
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
327
|
+
returnAudioTranscoder?.stop();
|
|
328
|
+
returnAudioTranscodedSplitter?.close();
|
|
329
|
+
});
|
|
330
|
+
await returnAudioTranscoder.start();
|
|
339
331
|
}
|
|
340
332
|
listenForVideoPackets(startStreamRequest) {
|
|
333
|
+
const { targetAddress, video: { port }, } = this.prepareStreamRequest;
|
|
334
|
+
const { video: { mtu, pt: payloadType }, } = startStreamRequest;
|
|
335
|
+
const processor = new VideoProcessor(this.videoSsrc, this.videoSrtp, this.videoSplitter, targetAddress, port, mtu, payloadType);
|
|
336
|
+
this.streamingSession.addSubscriptions(this.streamingSession.onVideoRtp.subscribe((rtp) => {
|
|
337
|
+
processor.processPacket(rtp);
|
|
338
|
+
}));
|
|
339
|
+
this.streamingSession.requestKeyFrame();
|
|
340
|
+
}
|
|
341
|
+
listenForVideoRtspPackets(startStreamRequest) {
|
|
341
342
|
let sentVideo = false;
|
|
342
343
|
const { targetAddress, video: { port: videoPort }, } = this.prepareStreamRequest;
|
|
343
|
-
// use to encrypt video to HomeKit
|
|
344
344
|
const videoSrtpSession = new SrtpSession(getSessionConfig(this.videoSrtp));
|
|
345
345
|
this.videoSplitter.addMessageHandler(({ message }) => {
|
|
346
346
|
const rtp = RtpPacket.deSerialize(message);
|
|
@@ -349,7 +349,7 @@ export class StreamingSessionWrapper {
|
|
|
349
349
|
const encryptedPacket = videoSrtpSession.encrypt(rtp.payload, rtp.header);
|
|
350
350
|
if (!sentVideo) {
|
|
351
351
|
sentVideo = true;
|
|
352
|
-
this.logger.debug(this.cameraDevice.name, `Received stream data (${getDurationSeconds(
|
|
352
|
+
this.logger.debug(this.cameraDevice.name, `Received stream data (${this.getDurationSeconds()}s)`);
|
|
353
353
|
}
|
|
354
354
|
this.videoSplitter.send(encryptedPacket, {
|
|
355
355
|
port: videoPort,
|
|
@@ -358,59 +358,96 @@ export class StreamingSessionWrapper {
|
|
|
358
358
|
return null;
|
|
359
359
|
});
|
|
360
360
|
}
|
|
361
|
-
|
|
361
|
+
listenForAudioPackets(startStreamRequest) {
|
|
362
362
|
const { targetAddress, audio: { port: audioPort }, } = this.prepareStreamRequest;
|
|
363
|
-
const { audio: {
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
const audioIntervalScale = ((audioSampleRate / 8) * audioPacketTime) / 20;
|
|
367
|
-
const audioSrtpSession = new SrtpSession(getSessionConfig(this.audioSrtp));
|
|
368
|
-
let firstTimestamp;
|
|
369
|
-
let audioPacketCount = 0;
|
|
363
|
+
const { audio: { pt: payloadType, codec, packet_time: packetTime, sample_rate: sampleRate }, } = startStreamRequest;
|
|
364
|
+
const isOpus = codec === "OPUS" /* AudioStreamingCodecType.OPUS */;
|
|
365
|
+
const processor = new AudioProcessor(this.audioSsrc, this.audioSrtp, this.audioSplitter, targetAddress, audioPort, payloadType, sampleRate, packetTime, isOpus);
|
|
370
366
|
this.repacketizeAudioSplitter.addMessageHandler(({ message }) => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
367
|
+
processor.processPacket(message);
|
|
368
|
+
return null;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async setupAddress() {
|
|
372
|
+
const { sessionID, targetAddress, addressVersion } = this.prepareStreamRequest;
|
|
373
|
+
let { sourceAddress } = this.prepareStreamRequest;
|
|
374
|
+
const socketType = addressVersion === 'ipv6' ? 'udp6' : 'udp4';
|
|
375
|
+
if (socketType === 'udp4' && sourceAddress.startsWith('::ffff:')) {
|
|
376
|
+
sourceAddress = sourceAddress.replace('::ffff:', '');
|
|
377
|
+
}
|
|
378
|
+
const serverAddresses = await this.cameraAccessory.api.coreManager.getServerAddresses();
|
|
379
|
+
const found = serverAddresses.find((address) => address.includes(sourceAddress));
|
|
380
|
+
if (!found && serverAddresses.length) {
|
|
381
|
+
this.logger.debug(this.cameraDevice.name, `Source address ${sourceAddress} not found in server addresses`);
|
|
382
|
+
const infos = Object.values(networkInterfaces())
|
|
383
|
+
.flat()
|
|
384
|
+
.map((i) => i?.address);
|
|
385
|
+
const targetAddresses = serverAddresses.filter((address) => {
|
|
386
|
+
if (socketType === 'udp4') {
|
|
387
|
+
return !isIPv6(address);
|
|
378
388
|
}
|
|
379
|
-
|
|
380
|
-
|
|
389
|
+
else {
|
|
390
|
+
return isIPv6(address);
|
|
381
391
|
}
|
|
382
|
-
// from HAP spec:
|
|
383
|
-
// RTP Payload Format for Opus Speech and Audio Codec RFC 7587 with an exception
|
|
384
|
-
// that Opus audio RTP Timestamp shall be based on RFC 3550.
|
|
385
|
-
// RFC 3550 indicates that PCM audio based with a sample rate of 8k and a packet
|
|
386
|
-
// time of 20ms would have a monotonic interval of 8k / (1000 / 20) = 160.
|
|
387
|
-
// So 24k audio would have a monotonic interval of (24k / 8k) * 160 = 480.
|
|
388
|
-
// HAP spec also states that it may request packet times of 20, 30, 40, or 60.
|
|
389
|
-
// In practice, HAP has been seen to request 20 on LAN and 60 over LTE.
|
|
390
|
-
// So the RTP timestamp must scale accordingly.
|
|
391
|
-
// Further investigation indicates that HAP doesn't care about the actual sample rate at all,
|
|
392
|
-
// that's merely a suggestion. When encoding Opus, it can seemingly be an arbitrary sample rate,
|
|
393
|
-
// audio will work so long as the rtp timestamps are created properly: which is a construct of the sample rate
|
|
394
|
-
// HAP requests, and the packet time is respected,
|
|
395
|
-
// opus 48khz will work just fine.
|
|
396
|
-
rtp.header.timestamp = (firstTimestamp + audioPacketCount * 160 * audioIntervalScale) % 0xffffffff;
|
|
397
|
-
audioPacketCount++;
|
|
398
|
-
}
|
|
399
|
-
// encrypt the packet
|
|
400
|
-
const encryptedPacket = audioSrtpSession.encrypt(rtp.payload, rtp.header);
|
|
401
|
-
// send the encrypted packet to HomeKit
|
|
402
|
-
this.audioSplitter.send(encryptedPacket, {
|
|
403
|
-
port: audioPort,
|
|
404
|
-
address: targetAddress,
|
|
405
392
|
});
|
|
406
|
-
|
|
407
|
-
|
|
393
|
+
const targetAddressFound = infos.find((address) => targetAddresses.includes(address));
|
|
394
|
+
if (targetAddressFound) {
|
|
395
|
+
this.logger.debug(this.cameraDevice.name, `Using target address ${targetAddressFound}`);
|
|
396
|
+
sourceAddress = targetAddressFound;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (found) {
|
|
400
|
+
this.logger.debug(this.cameraDevice.name, `Using source address ${sourceAddress}`);
|
|
401
|
+
}
|
|
402
|
+
return { socketType, sessionID, sourceAddress, targetAddress, addressVersion };
|
|
408
403
|
}
|
|
409
|
-
|
|
410
|
-
return
|
|
404
|
+
opusTranscodeArgs(startStreamRequest) {
|
|
405
|
+
return [
|
|
406
|
+
'-acodec',
|
|
407
|
+
'libopus',
|
|
408
|
+
'-min_comp',
|
|
409
|
+
'0',
|
|
410
|
+
'-application',
|
|
411
|
+
'lowdelay',
|
|
412
|
+
'-frame_duration',
|
|
413
|
+
startStreamRequest.audio.packet_time.toString(),
|
|
414
|
+
'-flags',
|
|
415
|
+
'+global_header',
|
|
416
|
+
'-ac',
|
|
417
|
+
`${startStreamRequest.audio.channel}`,
|
|
418
|
+
'-ar',
|
|
419
|
+
`${startStreamRequest.audio.sample_rate}k`,
|
|
420
|
+
'-b:a',
|
|
421
|
+
`${startStreamRequest.audio.max_bit_rate}k`,
|
|
422
|
+
'-bufsize',
|
|
423
|
+
`${startStreamRequest.audio.max_bit_rate * 4}k`,
|
|
424
|
+
];
|
|
411
425
|
}
|
|
412
|
-
|
|
413
|
-
return [
|
|
426
|
+
aacTranscodeArgs(startStreamRequest) {
|
|
427
|
+
return [
|
|
428
|
+
'-acodec',
|
|
429
|
+
'libfdk_aac',
|
|
430
|
+
'-profile:a',
|
|
431
|
+
'aac_eld',
|
|
432
|
+
'-eld_sbr:a',
|
|
433
|
+
'1',
|
|
434
|
+
'-eld_v2',
|
|
435
|
+
'1',
|
|
436
|
+
'-frame_size',
|
|
437
|
+
(startStreamRequest.audio.packet_time * startStreamRequest.audio.sample_rate).toString(),
|
|
438
|
+
'-f',
|
|
439
|
+
'null',
|
|
440
|
+
'-flags',
|
|
441
|
+
'+global_header',
|
|
442
|
+
'-ac',
|
|
443
|
+
`${startStreamRequest.audio.channel}`,
|
|
444
|
+
'-ar',
|
|
445
|
+
`${startStreamRequest.audio.sample_rate}k`,
|
|
446
|
+
'-b:a',
|
|
447
|
+
`${startStreamRequest.audio.max_bit_rate}k`,
|
|
448
|
+
'-bufsize',
|
|
449
|
+
`${startStreamRequest.audio.max_bit_rate * 4}k`,
|
|
450
|
+
];
|
|
414
451
|
}
|
|
415
452
|
getH264Level(level, numeric = false) {
|
|
416
453
|
switch (level) {
|
|
@@ -436,5 +473,11 @@ export class StreamingSessionWrapper {
|
|
|
436
473
|
return numeric ? '77' : 'main';
|
|
437
474
|
}
|
|
438
475
|
}
|
|
476
|
+
isLowBandwidth(startStreamRequest) {
|
|
477
|
+
return startStreamRequest.audio.packet_time >= 60;
|
|
478
|
+
}
|
|
479
|
+
getDurationSeconds() {
|
|
480
|
+
return (Date.now() - this.start) / 1000;
|
|
481
|
+
}
|
|
439
482
|
}
|
|
440
483
|
//# sourceMappingURL=sessionWrapper.js.map
|