@matterbridge/core 3.10.9-dev-20260907-5fcc59a → 3.10.9-dev-20260910-ca91bcb
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/behaviors/cameraAvSettingsUserLevelManagementServer.d.ts +24 -0
- package/dist/behaviors/cameraAvSettingsUserLevelManagementServer.js +53 -0
- package/dist/behaviors/cameraAvStreamManagementServer.d.ts +80 -0
- package/dist/behaviors/cameraAvStreamManagementServer.js +414 -0
- package/dist/behaviors/chimeServer.d.ts +12 -0
- package/dist/behaviors/chimeServer.js +37 -0
- package/dist/behaviors/clients.d.ts +4 -0
- package/dist/behaviors/clients.js +33 -0
- package/dist/behaviors/ffmpeg.d.ts +4 -0
- package/dist/behaviors/ffmpeg.js +103 -0
- package/dist/behaviors/snapshot.d.ts +15 -0
- package/dist/behaviors/snapshot.js +139 -0
- package/dist/behaviors/webRtcTransportProviderServer.d.ts +28 -0
- package/dist/behaviors/webRtcTransportProviderServer.js +422 -0
- package/dist/behaviors/webRtcTransportRequestorServer.d.ts +10 -0
- package/dist/behaviors/webRtcTransportRequestorServer.js +24 -0
- package/dist/behaviors/weriftSession.d.ts +63 -0
- package/dist/behaviors/weriftSession.js +538 -0
- package/dist/devices/audioDoorbell.d.ts +20 -0
- package/dist/devices/audioDoorbell.js +49 -0
- package/dist/devices/camera.d.ts +37 -0
- package/dist/devices/camera.js +64 -0
- package/dist/devices/chime.d.ts +16 -0
- package/dist/devices/chime.js +37 -0
- package/dist/devices/doorbell.d.ts +11 -0
- package/dist/devices/doorbell.js +31 -0
- package/dist/devices/floodlightCamera.d.ts +19 -0
- package/dist/devices/floodlightCamera.js +81 -0
- package/dist/devices/intercom.d.ts +22 -0
- package/dist/devices/intercom.js +55 -0
- package/dist/devices/snapshotCamera.d.ts +20 -0
- package/dist/devices/snapshotCamera.js +51 -0
- package/dist/devices/videoDoorbell.d.ts +21 -0
- package/dist/devices/videoDoorbell.js +82 -0
- package/dist/matterbridgeEndpoint.d.ts +4 -1
- package/dist/matterbridgeEndpoint.js +4 -1
- package/dist/matterbridgeEndpointHelpers.d.ts +3 -0
- package/dist/matterbridgeEndpointHelpers.js +51 -2
- package/dist/matterbridgeEndpointTypes.d.ts +15 -0
- package/dist/matterbridgeEndpointTypes.js +1 -2
- package/package.json +6 -5
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { createSocket } from 'node:dgram';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { fireAndForget, getErrorMessage } from '@matterbridge/utils';
|
|
4
|
+
import { AnsiLogger, MAGENTA } from 'node-ansi-logger';
|
|
5
|
+
import { RTCPeerConnection, RTCRtpCodecParameters, useH264, useOPUS, usePCMU, useVP8 } from 'werift';
|
|
6
|
+
import { navigator } from 'werift/nonstandard';
|
|
7
|
+
import { hasFfmpeg, runFfmpeg } from './ffmpeg.js';
|
|
8
|
+
export class WeriftWebRtcSession {
|
|
9
|
+
static activeSessions = new Set();
|
|
10
|
+
static exitHandlerRegistered = false;
|
|
11
|
+
peerConnection;
|
|
12
|
+
log;
|
|
13
|
+
webRtcSessionId;
|
|
14
|
+
options;
|
|
15
|
+
testVideoGenerator;
|
|
16
|
+
testVideoUdpDisposer;
|
|
17
|
+
testVideoAttached = false;
|
|
18
|
+
testAudioGenerator;
|
|
19
|
+
testAudioUdpDisposer;
|
|
20
|
+
testAudioAttached = false;
|
|
21
|
+
diagnosticsInterval;
|
|
22
|
+
static DIAGNOSTICS_INTERVAL_MS = 10_000;
|
|
23
|
+
lastDiagnosticsCounters;
|
|
24
|
+
dtlsTransportsWithStateLogging = new Set();
|
|
25
|
+
constructor(webRtcSessionId, options) {
|
|
26
|
+
this.webRtcSessionId = webRtcSessionId;
|
|
27
|
+
this.options = { ...options };
|
|
28
|
+
this.peerConnection = new RTCPeerConnection({ codecs: { audio: [useOPUS(), usePCMU()], video: [useH264(), useVP8()] } });
|
|
29
|
+
this.log = new AnsiLogger({ logName: `WebRTC session ${webRtcSessionId}`, logLevel: "debug", logNameColor: MAGENTA, logTimestampFormat: 4 });
|
|
30
|
+
this.peerConnection.iceGatheringStateChange.subscribe((state) => {
|
|
31
|
+
this.log.info(`ICE gathering state: ${state}`);
|
|
32
|
+
});
|
|
33
|
+
this.peerConnection.onIceCandidate.subscribe((candidate) => {
|
|
34
|
+
this.log.debug(candidate ? `Gathered local ICE candidate: ${candidate.candidate}` : 'ICE candidate gathering completed');
|
|
35
|
+
});
|
|
36
|
+
this.peerConnection.iceConnectionStateChange.subscribe((state) => {
|
|
37
|
+
this.log.info(`ICE connection state: ${state}`);
|
|
38
|
+
if (state === 'connected' || state === 'completed') {
|
|
39
|
+
for (const transport of this.peerConnection.iceTransports) {
|
|
40
|
+
const pair = transport.connection.nominated;
|
|
41
|
+
if (!pair)
|
|
42
|
+
continue;
|
|
43
|
+
const local = pair.localCandidate;
|
|
44
|
+
const remote = pair.remoteCandidate;
|
|
45
|
+
this.log.info(`Selected ICE candidate pair: local=${local.host}:${local.port} (${local.type}/${local.transport}) ` +
|
|
46
|
+
`remote=${remote.host}:${remote.port} (${remote.type}/${remote.transport})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
this.peerConnection.connectionStateChange.subscribe((state) => {
|
|
51
|
+
this.log.info(`Peer connection state: ${state}`);
|
|
52
|
+
});
|
|
53
|
+
this.log.debug(`Created RTCPeerConnection with codecs: audio=[OPUS, PCMU], video=[H264, VP8] for session ${webRtcSessionId}`);
|
|
54
|
+
this.diagnosticsInterval = setInterval(() => void this.logDiagnosticsSnapshot(), WeriftWebRtcSession.DIAGNOSTICS_INTERVAL_MS);
|
|
55
|
+
WeriftWebRtcSession.activeSessions.add(this);
|
|
56
|
+
if (!WeriftWebRtcSession.exitHandlerRegistered) {
|
|
57
|
+
WeriftWebRtcSession.exitHandlerRegistered = true;
|
|
58
|
+
process.on('exit', () => {
|
|
59
|
+
for (const session of WeriftWebRtcSession.activeSessions) {
|
|
60
|
+
if (!session.testVideoGenerator && !session.testAudioGenerator)
|
|
61
|
+
continue;
|
|
62
|
+
session.log.info('Process exiting: killing leftover ffmpeg processes for this session');
|
|
63
|
+
session.testVideoGenerator?.kill('SIGTERM');
|
|
64
|
+
session.testAudioGenerator?.kill('SIGTERM');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
summarizeSdp(sdp) {
|
|
70
|
+
const mediaKinds = sdp
|
|
71
|
+
.split('\n')
|
|
72
|
+
.map((line) => line.trim())
|
|
73
|
+
.filter((line) => line.startsWith('m='))
|
|
74
|
+
.map((line) => line.slice(2).split(' ')[0]);
|
|
75
|
+
return `length=${sdp.length} media=[${mediaKinds.join(',')}]`;
|
|
76
|
+
}
|
|
77
|
+
async getFreeUdpPort() {
|
|
78
|
+
return await new Promise((resolve, reject) => {
|
|
79
|
+
const socket = createSocket('udp4');
|
|
80
|
+
socket.once('error', reject);
|
|
81
|
+
socket.bind(0, '127.0.0.1', () => {
|
|
82
|
+
const port = socket.address().port;
|
|
83
|
+
this.log.debug(`Allocated free UDP port ${port}`);
|
|
84
|
+
socket.close(() => resolve(port));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
getPreferredInjectableVideoCodec() {
|
|
89
|
+
for (const transceiver of this.peerConnection.getTransceivers()) {
|
|
90
|
+
if (transceiver.kind !== 'video')
|
|
91
|
+
continue;
|
|
92
|
+
const preferredCodec = transceiver.codecs.find((codec) => {
|
|
93
|
+
const mimeType = codec.mimeType.toLowerCase();
|
|
94
|
+
return mimeType === 'video/vp8' || mimeType === 'video/h264';
|
|
95
|
+
});
|
|
96
|
+
if (preferredCodec) {
|
|
97
|
+
this.log.debug(`Preferred injectable video codec: ${preferredCodec.mimeType}`);
|
|
98
|
+
return preferredCodec;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.log.debug('No preferred injectable video codec (VP8/H264) negotiated on any video transceiver');
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
getPreferredInjectableAudioCodec() {
|
|
105
|
+
for (const transceiver of this.peerConnection.getTransceivers()) {
|
|
106
|
+
if (transceiver.kind !== 'audio')
|
|
107
|
+
continue;
|
|
108
|
+
const preferredCodec = transceiver.codecs.find((codec) => codec.mimeType.toLowerCase() === 'audio/opus');
|
|
109
|
+
if (preferredCodec) {
|
|
110
|
+
this.log.debug(`Preferred injectable audio codec: ${preferredCodec.mimeType}`);
|
|
111
|
+
return preferredCodec;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.log.debug('No preferred injectable audio codec (Opus) negotiated on any audio transceiver');
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
static SUPPORTED_VIDEO_RESOLUTIONS = ['640x480', '1280x720', '1920x1080'];
|
|
118
|
+
static DEFAULT_BITRATE_KBPS = 1000;
|
|
119
|
+
getConfiguredVideoBitrate() {
|
|
120
|
+
const configured = this.options.videoBitrate;
|
|
121
|
+
if (configured === undefined)
|
|
122
|
+
return WeriftWebRtcSession.DEFAULT_BITRATE_KBPS;
|
|
123
|
+
const bitrateKbps = configured;
|
|
124
|
+
if (!Number.isFinite(bitrateKbps) || bitrateKbps <= 0) {
|
|
125
|
+
this.log.warn(`Invalid options.videoBitrate "${configured}"; falling back to ${WeriftWebRtcSession.DEFAULT_BITRATE_KBPS}kbps`);
|
|
126
|
+
return WeriftWebRtcSession.DEFAULT_BITRATE_KBPS;
|
|
127
|
+
}
|
|
128
|
+
return bitrateKbps;
|
|
129
|
+
}
|
|
130
|
+
getConfiguredVideoResolution() {
|
|
131
|
+
const [defaultResolution] = WeriftWebRtcSession.SUPPORTED_VIDEO_RESOLUTIONS;
|
|
132
|
+
const configured = this.options.videoResolution;
|
|
133
|
+
if (configured && configured !== 'auto') {
|
|
134
|
+
if (WeriftWebRtcSession.SUPPORTED_VIDEO_RESOLUTIONS.includes(configured)) {
|
|
135
|
+
this.log.debug(`Using configured options.videoResolution ${configured}`);
|
|
136
|
+
return configured;
|
|
137
|
+
}
|
|
138
|
+
this.log.warn(`Unsupported options.videoResolution "${configured}" (supported: ${WeriftWebRtcSession.SUPPORTED_VIDEO_RESOLUTIONS.join(', ')}, auto); falling back to ${defaultResolution}`);
|
|
139
|
+
return defaultResolution;
|
|
140
|
+
}
|
|
141
|
+
return defaultResolution;
|
|
142
|
+
}
|
|
143
|
+
getConfiguredVideoSource() {
|
|
144
|
+
const source = this.options.videoSource?.trim().toLowerCase() ?? 'none';
|
|
145
|
+
switch (source) {
|
|
146
|
+
case 'none':
|
|
147
|
+
case 'test':
|
|
148
|
+
case 'webcam':
|
|
149
|
+
case 'rtsp':
|
|
150
|
+
return source;
|
|
151
|
+
default:
|
|
152
|
+
this.log.warn(`Unsupported options.videoSource "${source}" (supported: test, webcam, rtsp, none); falling back to none`);
|
|
153
|
+
return 'none';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
buildFfmpegVideoInputArgs(videoSource) {
|
|
157
|
+
const testPatternInput = {
|
|
158
|
+
args: ['-re', '-f', 'lavfi', '-i', 'testsrc=size=640x480:rate=10'],
|
|
159
|
+
description: 'synthetic moving test pattern',
|
|
160
|
+
bitrateKbps: WeriftWebRtcSession.DEFAULT_BITRATE_KBPS,
|
|
161
|
+
};
|
|
162
|
+
if (videoSource === 'test') {
|
|
163
|
+
this.log.debug(`Test pattern params: resolution=640x480, description="${testPatternInput.description}", bitrateKbps=${testPatternInput.bitrateKbps}`);
|
|
164
|
+
return testPatternInput;
|
|
165
|
+
}
|
|
166
|
+
const device = this.options.videoSourceDevice;
|
|
167
|
+
if (!device) {
|
|
168
|
+
this.log.warn(`options.videoSource=${videoSource} requires options.videoSourceDevice to be set; falling back to the synthetic test video`);
|
|
169
|
+
return testPatternInput;
|
|
170
|
+
}
|
|
171
|
+
const resolution = this.getConfiguredVideoResolution();
|
|
172
|
+
const bitrateKbps = this.getConfiguredVideoBitrate();
|
|
173
|
+
if (videoSource === 'rtsp') {
|
|
174
|
+
const description = `RTSP camera (${device}, ${resolution})`;
|
|
175
|
+
this.log.debug(`RTSP capture params: url=${device}, resolution=${resolution}, description="${description}", bitrateKbps=${bitrateKbps}`);
|
|
176
|
+
return { args: ['-rtsp_transport', 'tcp', '-i', device, '-vf', `scale=${resolution.replace('x', ':')}`], description, bitrateKbps };
|
|
177
|
+
}
|
|
178
|
+
const description = `local webcam (${device}, ${resolution})`;
|
|
179
|
+
this.log.debug(`Webcam capture params: device=${device}, resolution=${resolution}, description="${description}", bitrateKbps=${bitrateKbps}`);
|
|
180
|
+
switch (process.platform) {
|
|
181
|
+
case 'linux':
|
|
182
|
+
return { args: ['-f', 'v4l2', '-video_size', resolution, '-framerate', '30', '-i', device], description, bitrateKbps };
|
|
183
|
+
case 'darwin':
|
|
184
|
+
return { args: ['-f', 'avfoundation', '-video_size', resolution, '-framerate', '30', '-i', device], description, bitrateKbps };
|
|
185
|
+
case 'win32':
|
|
186
|
+
return { args: ['-f', 'dshow', '-video_size', resolution, '-framerate', '30', '-i', `video=${device}`], description, bitrateKbps };
|
|
187
|
+
default:
|
|
188
|
+
this.log.warn(`Webcam capture via ffmpeg is not supported on platform "${process.platform}"; falling back to the synthetic test video`);
|
|
189
|
+
return testPatternInput;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async generateVideoTrack(codec) {
|
|
193
|
+
if (this.testVideoAttached)
|
|
194
|
+
return;
|
|
195
|
+
const videoSource = this.getConfiguredVideoSource();
|
|
196
|
+
if (videoSource === 'none') {
|
|
197
|
+
this.log.debug('Video injection disabled by options.videoSource=none');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const videoInput = this.buildFfmpegVideoInputArgs(videoSource);
|
|
201
|
+
this.log.debug(`Attempting to attach ${videoInput.description} video track at ${videoInput.bitrateKbps}kbps`);
|
|
202
|
+
if (!hasFfmpeg()) {
|
|
203
|
+
this.log.warn('Cannot inject video stream: missing dependency ffmpeg');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const selectedMimeType = (codec?.mimeType ?? 'video/vp8').toLowerCase();
|
|
207
|
+
const selectedPayloadType = codec?.payloadType ?? 120;
|
|
208
|
+
try {
|
|
209
|
+
const udpPort = await this.getFreeUdpPort();
|
|
210
|
+
const { track, disposer } = navigator.mediaDevices.getUdpMedia({
|
|
211
|
+
port: udpPort,
|
|
212
|
+
codec: new RTCRtpCodecParameters({ mimeType: selectedMimeType, clockRate: 90000, payloadType: selectedPayloadType }),
|
|
213
|
+
});
|
|
214
|
+
this.peerConnection.addTrack(track);
|
|
215
|
+
const bitrate = `${videoInput.bitrateKbps}k`;
|
|
216
|
+
const encoderArgs = selectedMimeType === 'video/h264'
|
|
217
|
+
? ['-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', '20', '-b:v', bitrate, '-maxrate', bitrate, '-bufsize', bitrate]
|
|
218
|
+
: ['-c:v', 'libvpx', '-deadline', 'realtime', '-cpu-used', '4', '-pix_fmt', 'yuv420p', '-g', '20', '-b:v', bitrate, '-maxrate', bitrate, '-bufsize', bitrate];
|
|
219
|
+
const ffmpegArgs = [
|
|
220
|
+
'-hide_banner',
|
|
221
|
+
'-loglevel',
|
|
222
|
+
'error',
|
|
223
|
+
...videoInput.args,
|
|
224
|
+
'-an',
|
|
225
|
+
...encoderArgs,
|
|
226
|
+
'-f',
|
|
227
|
+
'rtp',
|
|
228
|
+
'-payload_type',
|
|
229
|
+
String(selectedPayloadType),
|
|
230
|
+
`rtp://127.0.0.1:${udpPort}`,
|
|
231
|
+
];
|
|
232
|
+
const generator = runFfmpeg(ffmpegArgs);
|
|
233
|
+
generator.once('error', (error) => {
|
|
234
|
+
this.log.warn(`Video generator failed: ${getErrorMessage(error)}`);
|
|
235
|
+
});
|
|
236
|
+
this.testVideoUdpDisposer = disposer;
|
|
237
|
+
this.testVideoGenerator = generator;
|
|
238
|
+
this.testVideoAttached = true;
|
|
239
|
+
this.log.info(`Attached ${videoInput.description} video track (codec=${selectedMimeType}, payloadType=${selectedPayloadType}, sourcePort=${udpPort})`);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
this.log.warn(`Failed to attach ${videoInput.description} video track: ${getErrorMessage(error)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
preferVideoCodecOnTransceivers(mimeType) {
|
|
246
|
+
let adjustedTransceivers = 0;
|
|
247
|
+
for (const transceiver of this.peerConnection.getTransceivers()) {
|
|
248
|
+
if (transceiver.kind !== 'video')
|
|
249
|
+
continue;
|
|
250
|
+
const preferredCodecs = transceiver.codecs.filter((codec) => codec.mimeType.toLowerCase() === mimeType);
|
|
251
|
+
if (!preferredCodecs.length)
|
|
252
|
+
continue;
|
|
253
|
+
transceiver.codecs = preferredCodecs;
|
|
254
|
+
adjustedTransceivers += 1;
|
|
255
|
+
}
|
|
256
|
+
if (adjustedTransceivers > 0) {
|
|
257
|
+
this.log.debug(`Preferred ${mimeType.toUpperCase()} codecs on ${adjustedTransceivers} video transceiver(s)`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
preferAudioCodecOnTransceivers(mimeType) {
|
|
261
|
+
let adjustedTransceivers = 0;
|
|
262
|
+
for (const transceiver of this.peerConnection.getTransceivers()) {
|
|
263
|
+
if (transceiver.kind !== 'audio')
|
|
264
|
+
continue;
|
|
265
|
+
const preferredCodecs = transceiver.codecs.filter((codec) => codec.mimeType.toLowerCase() === mimeType);
|
|
266
|
+
if (!preferredCodecs.length)
|
|
267
|
+
continue;
|
|
268
|
+
transceiver.codecs = preferredCodecs;
|
|
269
|
+
adjustedTransceivers += 1;
|
|
270
|
+
}
|
|
271
|
+
if (adjustedTransceivers > 0) {
|
|
272
|
+
this.log.debug(`Preferred ${mimeType.toUpperCase()} codecs on ${adjustedTransceivers} audio transceiver(s)`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
static TEST_VOICE_PATH = fileURLToPath(new URL('../../assets/test-voice.opus', import.meta.url));
|
|
276
|
+
getConfiguredAudioSource() {
|
|
277
|
+
const source = this.options.audioSource?.trim().toLowerCase() ?? 'none';
|
|
278
|
+
switch (source) {
|
|
279
|
+
case 'none':
|
|
280
|
+
case 'test':
|
|
281
|
+
case 'microphone':
|
|
282
|
+
case 'rtsp':
|
|
283
|
+
return source;
|
|
284
|
+
default:
|
|
285
|
+
this.log.warn(`Unsupported options.audioSource "${source}" (supported: test, microphone, rtsp, none); falling back to none`);
|
|
286
|
+
return 'none';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
buildFfmpegAudioInputArgs(audioSource) {
|
|
290
|
+
const testVoiceInput = {
|
|
291
|
+
args: ['-re', '-stream_loop', '-1', '-i', WeriftWebRtcSession.TEST_VOICE_PATH],
|
|
292
|
+
description: 'recorded test-voice clip',
|
|
293
|
+
volumeFilter: 'volume=6dB',
|
|
294
|
+
};
|
|
295
|
+
if (audioSource === 'test')
|
|
296
|
+
return testVoiceInput;
|
|
297
|
+
const device = this.options.audioSourceDevice;
|
|
298
|
+
if (!device) {
|
|
299
|
+
this.log.warn(`options.audioSource=${audioSource} requires options.audioSourceDevice to be set; falling back to the recorded test-voice clip`);
|
|
300
|
+
return testVoiceInput;
|
|
301
|
+
}
|
|
302
|
+
if (audioSource === 'rtsp') {
|
|
303
|
+
const description = `RTSP camera audio (${device})`;
|
|
304
|
+
this.log.debug(`RTSP audio capture params: url=${device}, description="${description}"`);
|
|
305
|
+
return { args: ['-rtsp_transport', 'tcp', '-i', device], description };
|
|
306
|
+
}
|
|
307
|
+
const description = `local microphone (${device})`;
|
|
308
|
+
this.log.debug(`Microphone capture params: device=${device}, description="${description}"`);
|
|
309
|
+
switch (process.platform) {
|
|
310
|
+
case 'linux':
|
|
311
|
+
return { args: ['-f', 'alsa', '-i', device], description };
|
|
312
|
+
case 'darwin':
|
|
313
|
+
return { args: ['-f', 'avfoundation', '-i', `:${device}`], description };
|
|
314
|
+
case 'win32':
|
|
315
|
+
return { args: ['-f', 'dshow', '-i', `audio=${device}`], description };
|
|
316
|
+
default:
|
|
317
|
+
this.log.warn(`Microphone capture via ffmpeg is not supported on platform "${process.platform}"; falling back to the recorded test-voice clip`);
|
|
318
|
+
return testVoiceInput;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async generateAudioTrack(codec) {
|
|
322
|
+
if (this.testAudioAttached)
|
|
323
|
+
return;
|
|
324
|
+
const audioSource = this.getConfiguredAudioSource();
|
|
325
|
+
if (audioSource === 'none') {
|
|
326
|
+
this.log.debug('Audio injection disabled by options.audioSource=none');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const audioInput = this.buildFfmpegAudioInputArgs(audioSource);
|
|
330
|
+
this.log.debug(`Attempting to attach ${audioInput.description} audio track`);
|
|
331
|
+
if (!hasFfmpeg()) {
|
|
332
|
+
this.log.warn('Cannot inject audio stream: missing dependency ffmpeg');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const selectedMimeType = codec.mimeType.toLowerCase();
|
|
336
|
+
const selectedPayloadType = codec.payloadType;
|
|
337
|
+
const clockRate = codec.clockRate;
|
|
338
|
+
const channels = codec.channels;
|
|
339
|
+
try {
|
|
340
|
+
const udpPort = await this.getFreeUdpPort();
|
|
341
|
+
const { track, disposer } = navigator.mediaDevices.getUdpMedia({
|
|
342
|
+
port: udpPort,
|
|
343
|
+
codec: new RTCRtpCodecParameters({ mimeType: selectedMimeType, clockRate, channels, payloadType: selectedPayloadType }),
|
|
344
|
+
});
|
|
345
|
+
this.peerConnection.addTrack(track);
|
|
346
|
+
const ffmpegArgs = [
|
|
347
|
+
'-hide_banner',
|
|
348
|
+
'-loglevel',
|
|
349
|
+
'error',
|
|
350
|
+
...audioInput.args,
|
|
351
|
+
'-vn',
|
|
352
|
+
...(audioInput.volumeFilter ? ['-af', audioInput.volumeFilter] : []),
|
|
353
|
+
'-c:a',
|
|
354
|
+
'libopus',
|
|
355
|
+
'-b:a',
|
|
356
|
+
'32k',
|
|
357
|
+
'-ac',
|
|
358
|
+
String(channels),
|
|
359
|
+
'-ar',
|
|
360
|
+
String(clockRate),
|
|
361
|
+
'-f',
|
|
362
|
+
'rtp',
|
|
363
|
+
'-payload_type',
|
|
364
|
+
String(selectedPayloadType),
|
|
365
|
+
`rtp://127.0.0.1:${udpPort}`,
|
|
366
|
+
];
|
|
367
|
+
const generator = runFfmpeg(ffmpegArgs);
|
|
368
|
+
generator.once('error', (error) => {
|
|
369
|
+
this.log.warn(`Audio generator failed: ${getErrorMessage(error)}`);
|
|
370
|
+
});
|
|
371
|
+
this.testAudioUdpDisposer = disposer;
|
|
372
|
+
this.testAudioGenerator = generator;
|
|
373
|
+
this.testAudioAttached = true;
|
|
374
|
+
this.log.info(`Attached ${audioInput.description} audio track (codec=${selectedMimeType}, payloadType=${selectedPayloadType}, sourcePort=${udpPort})`);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
this.log.warn(`Failed to attach ${audioInput.description} audio track: ${getErrorMessage(error)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
cleanupTestVideoArtifacts() {
|
|
381
|
+
if (this.testVideoGenerator) {
|
|
382
|
+
this.testVideoGenerator.kill('SIGTERM');
|
|
383
|
+
this.testVideoGenerator = undefined;
|
|
384
|
+
}
|
|
385
|
+
this.testVideoUdpDisposer?.();
|
|
386
|
+
this.testVideoUdpDisposer = undefined;
|
|
387
|
+
this.testVideoAttached = false;
|
|
388
|
+
}
|
|
389
|
+
cleanupTestAudioArtifacts() {
|
|
390
|
+
if (this.testAudioGenerator) {
|
|
391
|
+
this.testAudioGenerator.kill('SIGTERM');
|
|
392
|
+
this.testAudioGenerator = undefined;
|
|
393
|
+
}
|
|
394
|
+
this.testAudioUdpDisposer?.();
|
|
395
|
+
this.testAudioUdpDisposer = undefined;
|
|
396
|
+
this.testAudioAttached = false;
|
|
397
|
+
}
|
|
398
|
+
applyOptionOverrides(overrides) {
|
|
399
|
+
if (!overrides)
|
|
400
|
+
return;
|
|
401
|
+
Object.assign(this.options, Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)));
|
|
402
|
+
}
|
|
403
|
+
async createOffer(overrides) {
|
|
404
|
+
this.applyOptionOverrides(overrides);
|
|
405
|
+
const options = this.options;
|
|
406
|
+
this.log.debug(`CreateOffer requested (video=${options.video}, audio=${options.audio}, videoResolution=${options.videoResolution ?? 'undefined'})`);
|
|
407
|
+
if (options.video) {
|
|
408
|
+
const preferredCodec = this.getPreferredInjectableVideoCodec();
|
|
409
|
+
if (preferredCodec) {
|
|
410
|
+
this.preferVideoCodecOnTransceivers(preferredCodec.mimeType.toLowerCase());
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
this.log.warn('No injectable video codec available on negotiated transceivers (supported: VP8, H264)');
|
|
414
|
+
}
|
|
415
|
+
await this.generateVideoTrack(preferredCodec);
|
|
416
|
+
if (!this.testVideoAttached)
|
|
417
|
+
this.peerConnection.addTransceiver('video', { direction: 'sendonly' });
|
|
418
|
+
}
|
|
419
|
+
if (options.audio)
|
|
420
|
+
this.peerConnection.addTransceiver('audio', { direction: 'sendonly' });
|
|
421
|
+
const offer = await this.peerConnection.createOffer();
|
|
422
|
+
await this.peerConnection.setLocalDescription(offer);
|
|
423
|
+
const sdp = this.peerConnection.localDescription.sdp;
|
|
424
|
+
this.log.info(`Created local SDP offer (${this.summarizeSdp(sdp)})`);
|
|
425
|
+
this.logDtlsTransportStateChanges();
|
|
426
|
+
return sdp;
|
|
427
|
+
}
|
|
428
|
+
async createAnswer(offerSdp, overrides) {
|
|
429
|
+
this.applyOptionOverrides(overrides);
|
|
430
|
+
this.log.debug(`CreateAnswer requested for remote offer (${this.summarizeSdp(offerSdp)}, videoResolution=${this.options.videoResolution ?? 'undefined'})`);
|
|
431
|
+
await this.peerConnection.setRemoteDescription({ type: 'offer', sdp: offerSdp });
|
|
432
|
+
const hasVideoTransceiver = this.peerConnection.getTransceivers().some((transceiver) => transceiver.kind === 'video');
|
|
433
|
+
this.log.debug(`Remote offer created video transceiver: ${hasVideoTransceiver}`);
|
|
434
|
+
if (hasVideoTransceiver) {
|
|
435
|
+
const preferredCodec = this.getPreferredInjectableVideoCodec();
|
|
436
|
+
if (preferredCodec) {
|
|
437
|
+
this.preferVideoCodecOnTransceivers(preferredCodec.mimeType.toLowerCase());
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
this.log.warn('No injectable video codec available on negotiated transceivers (supported: VP8, H264)');
|
|
441
|
+
}
|
|
442
|
+
await this.generateVideoTrack(preferredCodec);
|
|
443
|
+
}
|
|
444
|
+
const hasAudioTransceiver = this.peerConnection.getTransceivers().some((transceiver) => transceiver.kind === 'audio');
|
|
445
|
+
this.log.debug(`Remote offer created audio transceiver: ${hasAudioTransceiver}`);
|
|
446
|
+
if (hasAudioTransceiver) {
|
|
447
|
+
const preferredAudioCodec = this.getPreferredInjectableAudioCodec();
|
|
448
|
+
if (preferredAudioCodec) {
|
|
449
|
+
this.preferAudioCodecOnTransceivers(preferredAudioCodec.mimeType.toLowerCase());
|
|
450
|
+
await this.generateAudioTrack(preferredAudioCodec);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
this.log.warn('No injectable audio codec available on negotiated transceivers (supported: Opus)');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const transceiver of this.peerConnection.getTransceivers()) {
|
|
457
|
+
transceiver.setDirection('sendonly');
|
|
458
|
+
}
|
|
459
|
+
const answer = await this.peerConnection.createAnswer();
|
|
460
|
+
await this.peerConnection.setLocalDescription(answer);
|
|
461
|
+
const sdp = this.peerConnection.localDescription.sdp;
|
|
462
|
+
this.log.info(`Created local SDP answer (${this.summarizeSdp(sdp)})`);
|
|
463
|
+
this.logDtlsTransportStateChanges();
|
|
464
|
+
return sdp;
|
|
465
|
+
}
|
|
466
|
+
async applyAnswer(answerSdp) {
|
|
467
|
+
this.log.debug(`ApplyAnswer requested (${this.summarizeSdp(answerSdp)})`);
|
|
468
|
+
await this.peerConnection.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
|
469
|
+
this.log.info(`Remote SDP answer applied (signalingState=${this.peerConnection.signalingState})`);
|
|
470
|
+
this.logDtlsTransportStateChanges();
|
|
471
|
+
}
|
|
472
|
+
async addIceCandidate(candidate, sdpMid, sdpMLineIndex) {
|
|
473
|
+
this.log.debug(`Applying ICE candidate (mid=${sdpMid ?? 'null'}, mLine=${sdpMLineIndex ?? 'null'}, endOfCandidates=${candidate.trim() === ''})`);
|
|
474
|
+
await this.peerConnection.addIceCandidate({ candidate, sdpMid: sdpMid ?? undefined, sdpMLineIndex: sdpMLineIndex ?? undefined });
|
|
475
|
+
}
|
|
476
|
+
closing = false;
|
|
477
|
+
logDtlsTransportStateChanges() {
|
|
478
|
+
for (const transport of this.peerConnection.dtlsTransports) {
|
|
479
|
+
if (this.dtlsTransportsWithStateLogging.has(transport))
|
|
480
|
+
continue;
|
|
481
|
+
this.dtlsTransportsWithStateLogging.add(transport);
|
|
482
|
+
transport.onStateChange.subscribe((state) => {
|
|
483
|
+
this.log.info(`DTLS transport state: ${state}`);
|
|
484
|
+
if ((state === 'closed' || state === 'failed') && !this.closing) {
|
|
485
|
+
this.log.info(`Closing session ${this.webRtcSessionId}: DTLS transport reached ${state}`);
|
|
486
|
+
fireAndForget(this.close(), this.log, `Failed to auto-close session ${this.webRtcSessionId} after DTLS transport reached ${state}`);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async logDiagnosticsSnapshot() {
|
|
492
|
+
const iceStates = this.peerConnection.iceTransports.map((transport) => transport.state).join(',') || 'none';
|
|
493
|
+
const dtlsStates = this.peerConnection.dtlsTransports.map((transport) => transport.state).join(',') || 'none';
|
|
494
|
+
const stats = [...(await this.peerConnection.getStats()).values()];
|
|
495
|
+
const nominatedPair = stats
|
|
496
|
+
.filter((stat) => stat.type === 'candidate-pair')
|
|
497
|
+
.map((stat) => stat)
|
|
498
|
+
.find((pair) => pair.nominated === true);
|
|
499
|
+
const outboundRtp = stats.filter((stat) => stat.type === 'outbound-rtp').map((stat) => stat);
|
|
500
|
+
const candidatePairPacketsSent = nominatedPair?.packetsSent ?? 0;
|
|
501
|
+
const candidatePairPacketsReceived = nominatedPair?.packetsReceived ?? 0;
|
|
502
|
+
const outboundRtpPacketsSent = outboundRtp.reduce((sum, stat) => sum + (stat.packetsSent ?? 0), 0);
|
|
503
|
+
const previous = this.lastDiagnosticsCounters;
|
|
504
|
+
this.lastDiagnosticsCounters = { candidatePairPacketsSent, candidatePairPacketsReceived, outboundRtpPacketsSent };
|
|
505
|
+
const delta = (current, previousValue) => previousValue === undefined ? 'n/a' : `${current - previousValue >= 0 ? '+' : ''}${current - previousValue}`;
|
|
506
|
+
const outboundRtpSummary = outboundRtp.map((stat) => `${stat.kind ?? 'unknown'}:packetsSent=${stat.packetsSent ?? 0}`).join(', ') || 'none';
|
|
507
|
+
this.log.debug([
|
|
508
|
+
'Diagnostics:',
|
|
509
|
+
`- connectionState=${this.peerConnection.connectionState}`,
|
|
510
|
+
`- iceConnectionState=${this.peerConnection.iceConnectionState}`,
|
|
511
|
+
`- iceGatheringState=${this.peerConnection.iceGatheringState}`,
|
|
512
|
+
`- signalingState=${this.peerConnection.signalingState}`,
|
|
513
|
+
`- iceTransportStates=[${iceStates}]`,
|
|
514
|
+
`- dtlsTransportStates=[${dtlsStates}]`,
|
|
515
|
+
`- nominatedPair(packetsSent=${candidatePairPacketsSent} Δ${delta(candidatePairPacketsSent, previous?.candidatePairPacketsSent)}, packetsReceived=${candidatePairPacketsReceived} Δ${delta(candidatePairPacketsReceived, previous?.candidatePairPacketsReceived)})`,
|
|
516
|
+
`- outboundRtp[${outboundRtpSummary}] (totalPacketsSent Δ${delta(outboundRtpPacketsSent, previous?.outboundRtpPacketsSent)})`,
|
|
517
|
+
].join('\n'));
|
|
518
|
+
}
|
|
519
|
+
async close() {
|
|
520
|
+
if (this.closing)
|
|
521
|
+
return;
|
|
522
|
+
this.closing = true;
|
|
523
|
+
this.log.debug('Closing RTCPeerConnection');
|
|
524
|
+
clearInterval(this.diagnosticsInterval);
|
|
525
|
+
this.diagnosticsInterval = undefined;
|
|
526
|
+
await this.peerConnection.close();
|
|
527
|
+
this.cleanupTestVideoArtifacts();
|
|
528
|
+
this.cleanupTestAudioArtifacts();
|
|
529
|
+
WeriftWebRtcSession.activeSessions.delete(this);
|
|
530
|
+
this.log.info(`RTCPeerConnection closed (connectionState=${this.peerConnection.connectionState})`);
|
|
531
|
+
}
|
|
532
|
+
static async closeAll() {
|
|
533
|
+
for (const session of WeriftWebRtcSession.activeSessions) {
|
|
534
|
+
session.log.info(`Closing session ${session.webRtcSessionId} as part of closeAll()`);
|
|
535
|
+
await session.close();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { StreamUsage } from '@matter/types';
|
|
2
|
+
import { CameraAvStreamManagement } from '@matter/types/clusters/camera-av-stream-management';
|
|
3
|
+
import { Identify } from '@matter/types/clusters/identify';
|
|
4
|
+
import type { WeriftOfferOptions } from '../behaviors/weriftSession.js';
|
|
5
|
+
import { MatterbridgeEndpoint } from '../matterbridgeEndpoint.js';
|
|
6
|
+
import type { MatterbridgeEndpointOptions } from '../matterbridgeEndpointTypes.js';
|
|
7
|
+
export interface AudioDoorbellOptions extends MatterbridgeEndpointOptions {
|
|
8
|
+
identifyTime?: number;
|
|
9
|
+
identifyType?: Identify.IdentifyType;
|
|
10
|
+
powerSourceType?: 'Rechargeable' | 'Replaceable' | 'Battery' | 'Wired' | 'None';
|
|
11
|
+
maxContentBufferSize?: number;
|
|
12
|
+
maxNetworkBandwidth?: number;
|
|
13
|
+
supportedStreamUsages?: StreamUsage[];
|
|
14
|
+
streamUsagePriorities?: StreamUsage[];
|
|
15
|
+
microphoneCapabilities?: CameraAvStreamManagement.AudioCapabilities;
|
|
16
|
+
weriftOfferOptions?: WeriftOfferOptions;
|
|
17
|
+
}
|
|
18
|
+
export declare class AudioDoorbell extends MatterbridgeEndpoint {
|
|
19
|
+
constructor(name: string, serial: string, options?: AudioDoorbellOptions);
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { StreamUsage } from '@matter/types';
|
|
2
|
+
import { CameraAvStreamManagement } from '@matter/types/clusters/camera-av-stream-management';
|
|
3
|
+
import { Identify } from '@matter/types/clusters/identify';
|
|
4
|
+
import { createDefaultAudioCameraAvStreamManagementClusterServer } from '../behaviors/cameraAvStreamManagementServer.js';
|
|
5
|
+
import { addChimeClient, addWebRtcTransportRequestorClient } from '../behaviors/clients.js';
|
|
6
|
+
import { createDefaultWebRtcTransportProviderClusterServer } from '../behaviors/webRtcTransportProviderServer.js';
|
|
7
|
+
import { audioDoorbell, powerSource } from '../matterbridgeDeviceTypes.js';
|
|
8
|
+
import { MatterbridgeEndpoint } from '../matterbridgeEndpoint.js';
|
|
9
|
+
export class AudioDoorbell extends MatterbridgeEndpoint {
|
|
10
|
+
constructor(name, serial, options = {}) {
|
|
11
|
+
const { identifyTime = 0, identifyType = Identify.IdentifyType.None, powerSourceType = 'Wired', maxContentBufferSize = 65_536, maxNetworkBandwidth = 128_000, supportedStreamUsages = [StreamUsage.LiveView], streamUsagePriorities = supportedStreamUsages, microphoneCapabilities = { maxNumberOfChannels: 1, supportedCodecs: [CameraAvStreamManagement.AudioCodec.Opus], supportedSampleRates: [48000], supportedBitDepths: [16] }, weriftOfferOptions, id, number, tagList, mode, } = options;
|
|
12
|
+
super(powerSourceType === 'None' ? [audioDoorbell] : [audioDoorbell, powerSource], {
|
|
13
|
+
id: id ?? `${name.replaceAll(' ', '')}-${serial.replaceAll(' ', '')}`,
|
|
14
|
+
number,
|
|
15
|
+
tagList,
|
|
16
|
+
mode,
|
|
17
|
+
});
|
|
18
|
+
this.createDefaultIdentifyClusterServer(identifyTime, identifyType);
|
|
19
|
+
this.createDefaultBasicInformationClusterServer(name, serial, 0xfff1, 'Matterbridge', 0x8000, 'Matterbridge Audio Doorbell');
|
|
20
|
+
switch (powerSourceType) {
|
|
21
|
+
case 'Rechargeable':
|
|
22
|
+
this.createDefaultPowerSourceRechargeableBatteryClusterServer();
|
|
23
|
+
break;
|
|
24
|
+
case 'Replaceable':
|
|
25
|
+
this.createDefaultPowerSourceReplaceableBatteryClusterServer();
|
|
26
|
+
break;
|
|
27
|
+
case 'Battery':
|
|
28
|
+
this.createDefaultPowerSourceBatteryClusterServer();
|
|
29
|
+
break;
|
|
30
|
+
case 'Wired':
|
|
31
|
+
this.createDefaultPowerSourceWiredClusterServer();
|
|
32
|
+
break;
|
|
33
|
+
case 'None':
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
this.createDefaultMomentarySwitchClusterServer();
|
|
37
|
+
createDefaultAudioCameraAvStreamManagementClusterServer(this, {
|
|
38
|
+
maxContentBufferSize,
|
|
39
|
+
maxNetworkBandwidth,
|
|
40
|
+
supportedStreamUsages,
|
|
41
|
+
streamUsagePriorities,
|
|
42
|
+
microphoneCapabilities,
|
|
43
|
+
});
|
|
44
|
+
createDefaultWebRtcTransportProviderClusterServer(this, weriftOfferOptions);
|
|
45
|
+
addChimeClient(this);
|
|
46
|
+
addWebRtcTransportRequestorClient(this);
|
|
47
|
+
this.addRequiredClusters();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { StreamUsage } from '@matter/types';
|
|
2
|
+
import type { Viewport } from '@matter/types';
|
|
3
|
+
import type { CameraAvSettingsUserLevelManagement } from '@matter/types/clusters/camera-av-settings-user-level-management';
|
|
4
|
+
import { CameraAvStreamManagement } from '@matter/types/clusters/camera-av-stream-management';
|
|
5
|
+
import { Identify } from '@matter/types/clusters/identify';
|
|
6
|
+
import type { WeriftOfferOptions } from '../behaviors/weriftSession.js';
|
|
7
|
+
import { MatterbridgeEndpoint } from '../matterbridgeEndpoint.js';
|
|
8
|
+
import type { MatterbridgeEndpointOptions } from '../matterbridgeEndpointTypes.js';
|
|
9
|
+
export interface CameraOptions extends MatterbridgeEndpointOptions {
|
|
10
|
+
identifyTime?: number;
|
|
11
|
+
identifyType?: Identify.IdentifyType;
|
|
12
|
+
powerSourceType?: 'Rechargeable' | 'Replaceable' | 'Battery' | 'Wired' | 'None';
|
|
13
|
+
ptz?: boolean;
|
|
14
|
+
maxContentBufferSize?: number;
|
|
15
|
+
maxNetworkBandwidth?: number;
|
|
16
|
+
supportedStreamUsages?: StreamUsage[];
|
|
17
|
+
streamUsagePriorities?: StreamUsage[];
|
|
18
|
+
maxConcurrentEncoders?: number;
|
|
19
|
+
maxEncodedPixelRate?: number;
|
|
20
|
+
videoSensorParams?: CameraAvStreamManagement.VideoSensorParams;
|
|
21
|
+
minViewportResolution?: CameraAvStreamManagement.VideoResolution;
|
|
22
|
+
rateDistortionTradeOffPoints?: CameraAvStreamManagement.RateDistortionTradeOffPoints[];
|
|
23
|
+
currentFrameRate?: number;
|
|
24
|
+
viewport?: Viewport;
|
|
25
|
+
microphoneCapabilities?: CameraAvStreamManagement.AudioCapabilities;
|
|
26
|
+
snapshotCapabilities?: CameraAvStreamManagement.SnapshotCapabilities[];
|
|
27
|
+
panMin?: number;
|
|
28
|
+
panMax?: number;
|
|
29
|
+
tiltMin?: number;
|
|
30
|
+
tiltMax?: number;
|
|
31
|
+
zoomMax?: number;
|
|
32
|
+
mptzPosition?: CameraAvSettingsUserLevelManagement.Mptz;
|
|
33
|
+
weriftOfferOptions?: WeriftOfferOptions;
|
|
34
|
+
}
|
|
35
|
+
export declare class Camera extends MatterbridgeEndpoint {
|
|
36
|
+
constructor(name: string, serial: string, options?: CameraOptions);
|
|
37
|
+
}
|