@camera.ui/camera-ui-homekit 0.0.15 → 0.0.17
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 +277 -222
- 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,45 +114,92 @@ 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
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
ffmpegInput.push('-enc_time_base', '-1', '-fps_mode', 'passthrough', '-muxdelay', '0', '-video_track_timescale', '90000');
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
ffmpegInput.push('-avioflags', 'direct', '-rtsp_transport', 'tcp', '-i', this.cameraDevice.streamSource.urls.rtsp.default, ...this.silentAudioSource());
|
|
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
181
|
}
|
|
182
|
-
ffmpegInput
|
|
183
|
-
|
|
182
|
+
const ffmpegInput = [
|
|
183
|
+
'-hide_banner',
|
|
184
|
+
'-loglevel',
|
|
185
|
+
'verbose',
|
|
186
|
+
'-fflags',
|
|
187
|
+
'+discardcorrupt',
|
|
188
|
+
'-max_delay',
|
|
189
|
+
'500000',
|
|
184
190
|
'-flags',
|
|
185
|
-
'
|
|
186
|
-
'-
|
|
187
|
-
|
|
188
|
-
'-ar',
|
|
189
|
-
`${startStreamRequest.audio.sample_rate}k`,
|
|
190
|
-
'-b:a',
|
|
191
|
-
`${startStreamRequest.audio.max_bit_rate}k`,
|
|
192
|
-
'-bufsize',
|
|
193
|
-
`${startStreamRequest.audio.max_bit_rate * 4}k`,
|
|
191
|
+
'low_delay',
|
|
192
|
+
'-thread_queue_size',
|
|
193
|
+
'1024',
|
|
194
194
|
];
|
|
195
|
-
if (
|
|
196
|
-
|
|
195
|
+
if (isPrebufferingEnabled) {
|
|
196
|
+
ffmpegInput.push('-f', 'mpegts', '-i', mpegtsPrebufferingState.url);
|
|
197
|
+
ffmpegInput.push('-enc_time_base', '-1', '-muxdelay', '0', '-video_track_timescale', '90000');
|
|
197
198
|
}
|
|
198
199
|
else {
|
|
199
|
-
|
|
200
|
+
ffmpegInput.push('-avioflags', 'direct', '-rtsp_transport', 'tcp', '-i', this.cameraDevice.streamSource.urls.rtsp.default);
|
|
200
201
|
}
|
|
202
|
+
ffmpegInput.push('-fps_mode', 'passthrough', '-reset_timestamps', '1');
|
|
201
203
|
const videoCodec = [];
|
|
202
204
|
if (transcodeVideoStream) {
|
|
203
205
|
const idrInterval = 4;
|
|
@@ -227,21 +229,17 @@ export class StreamingSessionWrapper {
|
|
|
227
229
|
'rtp',
|
|
228
230
|
`rtp://${this.videoSplitter.address}:${this.videoSplitter.port}?pkt_size=${videoMtu}`,
|
|
229
231
|
];
|
|
230
|
-
const audioArgs = [
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
'-sn',
|
|
240
|
-
|
|
241
|
-
'-f',
|
|
242
|
-
'rtp',
|
|
243
|
-
`rtp://${this.repacketizeAudioSplitter.address}:${this.repacketizeAudioSplitter.port}?pkt_size=${audioMtu}`,
|
|
244
|
-
];
|
|
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
|
+
}
|
|
245
243
|
const ffmpegOutput = [];
|
|
246
244
|
const ffmpegArgs = [...ffmpegInput, ...videoArgs, ...audioArgs, ...ffmpegOutput];
|
|
247
245
|
this.logger.debug(this.cameraDevice.name, 'Starting RTSP session with ffmpeg', ffmpegPath, ffmpegArgs.join(' '));
|
|
@@ -264,71 +262,85 @@ export class StreamingSessionWrapper {
|
|
|
264
262
|
}
|
|
265
263
|
});
|
|
266
264
|
}
|
|
267
|
-
async createTwoWayAudioTranscoder(startStreamRequest) {
|
|
268
|
-
const twoWayAudio = await this.cameraAccessory.cameraStorage.getValue('twoWayAudio', true);
|
|
269
|
-
let returnAudioTranscodedSplitter;
|
|
270
|
-
let returnAudioTranscoder;
|
|
265
|
+
async createTwoWayAudioTranscoder(startStreamRequest, talkbackStream) {
|
|
271
266
|
const returnAudioCodecs = [];
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
|
293
304
|
}
|
|
294
|
-
|
|
295
|
-
|
|
305
|
+
catch {
|
|
306
|
+
// Ignore deserialization errors
|
|
296
307
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
321
|
-
this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
322
|
-
returnAudioTranscoder?.stop();
|
|
323
|
-
returnAudioTranscodedSplitter?.close();
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
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();
|
|
327
331
|
}
|
|
328
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) {
|
|
329
342
|
let sentVideo = false;
|
|
330
343
|
const { targetAddress, video: { port: videoPort }, } = this.prepareStreamRequest;
|
|
331
|
-
// use to encrypt video to HomeKit
|
|
332
344
|
const videoSrtpSession = new SrtpSession(getSessionConfig(this.videoSrtp));
|
|
333
345
|
this.videoSplitter.addMessageHandler(({ message }) => {
|
|
334
346
|
const rtp = RtpPacket.deSerialize(message);
|
|
@@ -337,7 +349,7 @@ export class StreamingSessionWrapper {
|
|
|
337
349
|
const encryptedPacket = videoSrtpSession.encrypt(rtp.payload, rtp.header);
|
|
338
350
|
if (!sentVideo) {
|
|
339
351
|
sentVideo = true;
|
|
340
|
-
this.logger.debug(this.cameraDevice.name, `Received stream data (${getDurationSeconds(
|
|
352
|
+
this.logger.debug(this.cameraDevice.name, `Received stream data (${this.getDurationSeconds()}s)`);
|
|
341
353
|
}
|
|
342
354
|
this.videoSplitter.send(encryptedPacket, {
|
|
343
355
|
port: videoPort,
|
|
@@ -346,59 +358,96 @@ export class StreamingSessionWrapper {
|
|
|
346
358
|
return null;
|
|
347
359
|
});
|
|
348
360
|
}
|
|
349
|
-
|
|
361
|
+
listenForAudioPackets(startStreamRequest) {
|
|
350
362
|
const { targetAddress, audio: { port: audioPort }, } = this.prepareStreamRequest;
|
|
351
|
-
const { audio: {
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const audioIntervalScale = ((audioSampleRate / 8) * audioPacketTime) / 20;
|
|
355
|
-
const audioSrtpSession = new SrtpSession(getSessionConfig(this.audioSrtp));
|
|
356
|
-
let firstTimestamp;
|
|
357
|
-
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);
|
|
358
366
|
this.repacketizeAudioSplitter.addMessageHandler(({ message }) => {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
|
366
388
|
}
|
|
367
|
-
|
|
368
|
-
|
|
389
|
+
else {
|
|
390
|
+
return isIPv6(address);
|
|
369
391
|
}
|
|
370
|
-
// from HAP spec:
|
|
371
|
-
// RTP Payload Format for Opus Speech and Audio Codec RFC 7587 with an exception
|
|
372
|
-
// that Opus audio RTP Timestamp shall be based on RFC 3550.
|
|
373
|
-
// RFC 3550 indicates that PCM audio based with a sample rate of 8k and a packet
|
|
374
|
-
// time of 20ms would have a monotonic interval of 8k / (1000 / 20) = 160.
|
|
375
|
-
// So 24k audio would have a monotonic interval of (24k / 8k) * 160 = 480.
|
|
376
|
-
// HAP spec also states that it may request packet times of 20, 30, 40, or 60.
|
|
377
|
-
// In practice, HAP has been seen to request 20 on LAN and 60 over LTE.
|
|
378
|
-
// So the RTP timestamp must scale accordingly.
|
|
379
|
-
// Further investigation indicates that HAP doesn't care about the actual sample rate at all,
|
|
380
|
-
// that's merely a suggestion. When encoding Opus, it can seemingly be an arbitrary sample rate,
|
|
381
|
-
// audio will work so long as the rtp timestamps are created properly: which is a construct of the sample rate
|
|
382
|
-
// HAP requests, and the packet time is respected,
|
|
383
|
-
// opus 48khz will work just fine.
|
|
384
|
-
rtp.header.timestamp = (firstTimestamp + audioPacketCount * 160 * audioIntervalScale) % 0xffffffff;
|
|
385
|
-
audioPacketCount++;
|
|
386
|
-
}
|
|
387
|
-
// encrypt the packet
|
|
388
|
-
const encryptedPacket = audioSrtpSession.encrypt(rtp.payload, rtp.header);
|
|
389
|
-
// send the encrypted packet to HomeKit
|
|
390
|
-
this.audioSplitter.send(encryptedPacket, {
|
|
391
|
-
port: audioPort,
|
|
392
|
-
address: targetAddress,
|
|
393
392
|
});
|
|
394
|
-
|
|
395
|
-
|
|
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 };
|
|
396
403
|
}
|
|
397
|
-
|
|
398
|
-
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
|
+
];
|
|
399
425
|
}
|
|
400
|
-
|
|
401
|
-
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
|
+
];
|
|
402
451
|
}
|
|
403
452
|
getH264Level(level, numeric = false) {
|
|
404
453
|
switch (level) {
|
|
@@ -424,5 +473,11 @@ export class StreamingSessionWrapper {
|
|
|
424
473
|
return numeric ? '77' : 'main';
|
|
425
474
|
}
|
|
426
475
|
}
|
|
476
|
+
isLowBandwidth(startStreamRequest) {
|
|
477
|
+
return startStreamRequest.audio.packet_time >= 60;
|
|
478
|
+
}
|
|
479
|
+
getDurationSeconds() {
|
|
480
|
+
return (Date.now() - this.start) / 1000;
|
|
481
|
+
}
|
|
427
482
|
}
|
|
428
483
|
//# sourceMappingURL=sessionWrapper.js.map
|