@voltkit-rtc/client 0.8.0 → 0.9.1
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/CHANGELOG.md +38 -0
- package/README.md +102 -0
- package/dist/cjs/diagnostics.d.ts +1 -1
- package/dist/cjs/diagnostics.js +1 -1
- package/dist/cjs/effects-core.d.ts +95 -0
- package/dist/cjs/effects-core.d.ts.map +1 -0
- package/dist/cjs/effects-core.js +154 -0
- package/dist/cjs/effects-core.js.map +1 -0
- package/dist/cjs/effects-gl.d.ts +79 -0
- package/dist/cjs/effects-gl.d.ts.map +1 -0
- package/dist/cjs/effects-gl.js +351 -0
- package/dist/cjs/effects-gl.js.map +1 -0
- package/dist/cjs/index.d.ts +57 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +278 -64
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/noise-suppression.d.ts +44 -0
- package/dist/cjs/noise-suppression.d.ts.map +1 -0
- package/dist/cjs/noise-suppression.js +103 -0
- package/dist/cjs/noise-suppression.js.map +1 -0
- package/dist/cjs/processors.d.ts +34 -0
- package/dist/cjs/processors.d.ts.map +1 -0
- package/dist/cjs/processors.js +10 -0
- package/dist/cjs/processors.js.map +1 -0
- package/dist/cjs/quality.d.ts +7 -1
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +2 -1
- package/dist/cjs/quality.js.map +1 -1
- package/dist/cjs/video-effects.d.ts +93 -0
- package/dist/cjs/video-effects.d.ts.map +1 -0
- package/dist/cjs/video-effects.js +493 -0
- package/dist/cjs/video-effects.js.map +1 -0
- package/dist/esm/diagnostics.d.ts +1 -1
- package/dist/esm/diagnostics.js +1 -1
- package/dist/esm/effects-core.d.ts +95 -0
- package/dist/esm/effects-core.d.ts.map +1 -0
- package/dist/esm/effects-core.js +142 -0
- package/dist/esm/effects-core.js.map +1 -0
- package/dist/esm/effects-gl.d.ts +79 -0
- package/dist/esm/effects-gl.d.ts.map +1 -0
- package/dist/esm/effects-gl.js +347 -0
- package/dist/esm/effects-gl.js.map +1 -0
- package/dist/esm/index.d.ts +57 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +278 -64
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/noise-suppression.d.ts +44 -0
- package/dist/esm/noise-suppression.d.ts.map +1 -0
- package/dist/esm/noise-suppression.js +99 -0
- package/dist/esm/noise-suppression.js.map +1 -0
- package/dist/esm/processors.d.ts +34 -0
- package/dist/esm/processors.d.ts.map +1 -0
- package/dist/esm/processors.js +9 -0
- package/dist/esm/processors.js.map +1 -0
- package/dist/esm/quality.d.ts +7 -1
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +2 -1
- package/dist/esm/quality.js.map +1 -1
- package/dist/esm/video-effects.d.ts +93 -0
- package/dist/esm/video-effects.d.ts.map +1 -0
- package/dist/esm/video-effects.js +488 -0
- package/dist/esm/video-effects.js.map +1 -0
- package/package.json +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/effects-core.ts +177 -0
- package/src/effects-gl.ts +384 -0
- package/src/index.ts +276 -63
- package/src/noise-suppression.ts +135 -0
- package/src/processors.ts +35 -0
- package/src/quality.ts +9 -2
- package/src/video-effects.ts +552 -0
package/dist/cjs/index.js
CHANGED
|
@@ -235,6 +235,8 @@ class StreamClient {
|
|
|
235
235
|
// server only remembers the last one, rejecting consumes on the first.
|
|
236
236
|
sendTransportPending = null;
|
|
237
237
|
recvTransportPending = null;
|
|
238
|
+
/** Transports the join made (P3-6), each used once by the first transport of its direction. */
|
|
239
|
+
preparedTransports = null;
|
|
238
240
|
pending = new Map();
|
|
239
241
|
listeners = new Map();
|
|
240
242
|
consumers = new Map();
|
|
@@ -331,6 +333,13 @@ class StreamClient {
|
|
|
331
333
|
healthTimer = null;
|
|
332
334
|
healthSampling = false;
|
|
333
335
|
interruptions = new Map();
|
|
336
|
+
/** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
|
|
337
|
+
audioProcessor = null;
|
|
338
|
+
micRaw = null;
|
|
339
|
+
videoProcessor = null;
|
|
340
|
+
/** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
|
|
341
|
+
camRaw = null;
|
|
342
|
+
camOut = null;
|
|
334
343
|
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
335
344
|
diagnosticsLog = new diagnostics_js_1.DiagnosticsLog();
|
|
336
345
|
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
@@ -588,8 +597,13 @@ class StreamClient {
|
|
|
588
597
|
const opening = this.openSocket();
|
|
589
598
|
socket = this.ws;
|
|
590
599
|
await opening;
|
|
591
|
-
|
|
600
|
+
// The transports come with the join where the server can make them (P3-6): the first publish and the first
|
|
601
|
+
// video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
|
|
602
|
+
const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
|
|
603
|
+
const { transports, ...joinResult } = joined;
|
|
604
|
+
result = joinResult;
|
|
592
605
|
this.joinResult = result;
|
|
606
|
+
this.preparedTransports = transports ?? null;
|
|
593
607
|
this.device = this.createDevice();
|
|
594
608
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
595
609
|
// close() ran while the Device loaded: going on would set the state
|
|
@@ -846,6 +860,14 @@ class StreamClient {
|
|
|
846
860
|
}
|
|
847
861
|
close() {
|
|
848
862
|
void this.stopRecording().catch(() => undefined);
|
|
863
|
+
// The processor's audio graph goes with the session.
|
|
864
|
+
const processor = this.audioProcessor;
|
|
865
|
+
this.audioProcessor = null;
|
|
866
|
+
this.micRaw = null;
|
|
867
|
+
void Promise.resolve(processor?.destroy()).catch(() => undefined);
|
|
868
|
+
const videoProcessor = this.videoProcessor;
|
|
869
|
+
this.videoProcessor = null;
|
|
870
|
+
void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
|
|
849
871
|
// Adaptive stream stops watching elements: nothing is received any more.
|
|
850
872
|
this.adaptive.dispose();
|
|
851
873
|
this.adaptivePaused.clear();
|
|
@@ -868,7 +890,7 @@ class StreamClient {
|
|
|
868
890
|
this.skippedVideos.clear();
|
|
869
891
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
870
892
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
871
|
-
this.
|
|
893
|
+
this.stopLocalTracks();
|
|
872
894
|
this.localStream = null;
|
|
873
895
|
const ws = this.ws;
|
|
874
896
|
this.ws = null;
|
|
@@ -882,6 +904,9 @@ class StreamClient {
|
|
|
882
904
|
this.emit('closed', 'left');
|
|
883
905
|
}
|
|
884
906
|
teardownMedia() {
|
|
907
|
+
this.micRaw = null;
|
|
908
|
+
// Made for the session that is going; a rejoin brings new ones.
|
|
909
|
+
this.preparedTransports = null;
|
|
885
910
|
this.mediaPaths.reset();
|
|
886
911
|
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
887
912
|
// app's "poor connection" overlay does not outlive it.
|
|
@@ -1365,18 +1390,14 @@ class StreamClient {
|
|
|
1365
1390
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1366
1391
|
const device = this.device;
|
|
1367
1392
|
const socket = this.ws;
|
|
1368
|
-
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
1393
|
+
const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
|
|
1369
1394
|
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1370
1395
|
throw closedWhileConnecting();
|
|
1371
1396
|
const transport = device.createSendTransport({
|
|
1372
1397
|
...params,
|
|
1373
1398
|
iceServers: this.joinResult?.iceServers,
|
|
1374
1399
|
});
|
|
1375
|
-
|
|
1376
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1377
|
-
.then(() => ok())
|
|
1378
|
-
.catch(fail);
|
|
1379
|
-
});
|
|
1400
|
+
this.connectEarly(transport);
|
|
1380
1401
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
1381
1402
|
this.rpc('produce', {
|
|
1382
1403
|
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
@@ -1389,6 +1410,33 @@ class StreamClient {
|
|
|
1389
1410
|
this.watchTransport(transport);
|
|
1390
1411
|
return transport;
|
|
1391
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
|
|
1415
|
+
* remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
|
|
1416
|
+
* trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
|
|
1417
|
+
* them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
|
|
1418
|
+
* role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
|
|
1419
|
+
* sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
|
|
1420
|
+
* straight after. A refusal means the transport can never carry media: the session rejoins.
|
|
1421
|
+
*/
|
|
1422
|
+
connectEarly(transport) {
|
|
1423
|
+
transport.on('connect', ({ dtlsParameters }, ok) => {
|
|
1424
|
+
ok();
|
|
1425
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
|
|
1426
|
+
if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
|
|
1427
|
+
return;
|
|
1428
|
+
this.emit('error', err);
|
|
1429
|
+
this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/** The transport the join made for this direction, once: a later one is created as before. */
|
|
1434
|
+
takePreparedTransport(direction) {
|
|
1435
|
+
const prepared = this.preparedTransports?.[direction] ?? null;
|
|
1436
|
+
if (this.preparedTransports)
|
|
1437
|
+
this.preparedTransports[direction] = undefined;
|
|
1438
|
+
return prepared;
|
|
1439
|
+
}
|
|
1392
1440
|
ensureRecvTransport() {
|
|
1393
1441
|
if (this.recvTransport)
|
|
1394
1442
|
return Promise.resolve(this.recvTransport);
|
|
@@ -1405,18 +1453,14 @@ class StreamClient {
|
|
|
1405
1453
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1406
1454
|
const device = this.device;
|
|
1407
1455
|
const socket = this.ws;
|
|
1408
|
-
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1456
|
+
const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1409
1457
|
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1410
1458
|
throw closedWhileConnecting();
|
|
1411
1459
|
const transport = device.createRecvTransport({
|
|
1412
1460
|
...params,
|
|
1413
1461
|
iceServers: this.joinResult?.iceServers,
|
|
1414
1462
|
});
|
|
1415
|
-
|
|
1416
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1417
|
-
.then(() => ok())
|
|
1418
|
-
.catch(fail);
|
|
1419
|
-
});
|
|
1463
|
+
this.connectEarly(transport);
|
|
1420
1464
|
this.recvTransport = transport;
|
|
1421
1465
|
this.watchTransport(transport);
|
|
1422
1466
|
return transport;
|
|
@@ -1529,7 +1573,8 @@ class StreamClient {
|
|
|
1529
1573
|
this.audioCapReported = false;
|
|
1530
1574
|
await this.micTask(async () => {
|
|
1531
1575
|
const current = this.micProducer();
|
|
1532
|
-
|
|
1576
|
+
// The device's track: the processed one is the old processor graph's, let go when it is processed again.
|
|
1577
|
+
const track = current ? this.deviceTrackOf(current) : null;
|
|
1533
1578
|
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1534
1579
|
return;
|
|
1535
1580
|
await this.reproduceMic(current, track);
|
|
@@ -1570,8 +1615,10 @@ class StreamClient {
|
|
|
1570
1615
|
throw unpublishedWhile('microphone');
|
|
1571
1616
|
const quality = this.audioQualityFor();
|
|
1572
1617
|
const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
|
|
1618
|
+
// Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
|
|
1619
|
+
const sent = await this.processMic(track);
|
|
1573
1620
|
const producer = await transport.produce({
|
|
1574
|
-
track,
|
|
1621
|
+
track: sent,
|
|
1575
1622
|
// Keep the track alive when the producer closes: a reconnect closes
|
|
1576
1623
|
// every producer, and a stopped track cannot be republished.
|
|
1577
1624
|
stopTracks: false,
|
|
@@ -1584,6 +1631,7 @@ class StreamClient {
|
|
|
1584
1631
|
appData: { source: 'mic', audioQuality: quality },
|
|
1585
1632
|
});
|
|
1586
1633
|
await this.keepProducer(producer, generation, 'microphone');
|
|
1634
|
+
this.micRaw = sent !== track ? track : null;
|
|
1587
1635
|
this.watchTrackEnded(producer);
|
|
1588
1636
|
this.micQuality = quality;
|
|
1589
1637
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
@@ -1656,8 +1704,9 @@ class StreamClient {
|
|
|
1656
1704
|
const track = current?.track;
|
|
1657
1705
|
if (!current || !track || track.readyState !== 'live')
|
|
1658
1706
|
return;
|
|
1707
|
+
const camera = this.cameraSource(track);
|
|
1659
1708
|
try {
|
|
1660
|
-
await
|
|
1709
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
|
|
1661
1710
|
}
|
|
1662
1711
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
1663
1712
|
await this.reproduceCamera(current, track);
|
|
@@ -1707,43 +1756,56 @@ class StreamClient {
|
|
|
1707
1756
|
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
1708
1757
|
if (!current && !reconnecting)
|
|
1709
1758
|
throw new Error('replaceVideoTrack: no camera is being published');
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
if (replaced && replaced !== track) {
|
|
1717
|
-
track.enabled = replaced.enabled;
|
|
1718
|
-
replaced.enabled = true;
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
this.adoptVideoTrack(track);
|
|
1722
|
-
if (!current || current.track === track)
|
|
1723
|
-
return;
|
|
1724
|
-
const previous = current.track;
|
|
1725
|
-
const paused = current.paused;
|
|
1759
|
+
// The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
|
|
1760
|
+
// processor too, when there is one.
|
|
1761
|
+
const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
1762
|
+
const previousSource = shown ? this.cameraSource(shown) : null;
|
|
1763
|
+
const before = this.camOut;
|
|
1764
|
+
const sent = await this.processCamera(track);
|
|
1726
1765
|
try {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1766
|
+
if (!current) {
|
|
1767
|
+
// The republish reads the camera's state off the track it sends, and
|
|
1768
|
+
// the track being replaced is switched off exactly when the camera is
|
|
1769
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
1770
|
+
// and the old track back switched on, as below.
|
|
1771
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
1772
|
+
if (replaced && replaced !== sent) {
|
|
1773
|
+
sent.enabled = replaced.enabled;
|
|
1774
|
+
replaced.enabled = true;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
this.adoptVideoTrack(sent);
|
|
1778
|
+
if (!current || current.track === sent)
|
|
1736
1779
|
return;
|
|
1780
|
+
const previous = current.track;
|
|
1781
|
+
const paused = current.paused;
|
|
1782
|
+
try {
|
|
1783
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
1784
|
+
const frameRate = this.frameRateFor(track);
|
|
1785
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
1786
|
+
const previousSize = previousSource?.getSettings();
|
|
1787
|
+
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
1788
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
1789
|
+
await current.replaceTrack({ track: sent });
|
|
1790
|
+
this.watchTrackEnded(current);
|
|
1791
|
+
await this.applyLayerBitrates(current, next);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
await this.reproduceCamera(current, sent);
|
|
1795
|
+
}
|
|
1796
|
+
finally {
|
|
1797
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
1798
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
1799
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
1800
|
+
// off, that canvas stays black after the camera is resumed.
|
|
1801
|
+
if (paused && previous && previous !== this.cameraProducer()?.track)
|
|
1802
|
+
previous.enabled = true;
|
|
1737
1803
|
}
|
|
1738
|
-
await this.reproduceCamera(current, track);
|
|
1739
1804
|
}
|
|
1740
1805
|
finally {
|
|
1741
|
-
//
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
// off, that canvas stays black after the camera is resumed.
|
|
1745
|
-
if (paused && previous && previous !== this.cameraProducer()?.track)
|
|
1746
|
-
previous.enabled = true;
|
|
1806
|
+
// The processor's previous picture is no longer sent or shown.
|
|
1807
|
+
if (before && before !== this.camOut)
|
|
1808
|
+
before.stop();
|
|
1747
1809
|
}
|
|
1748
1810
|
});
|
|
1749
1811
|
}
|
|
@@ -1924,18 +1986,29 @@ class StreamClient {
|
|
|
1924
1986
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
1925
1987
|
if (generation !== this.publishGeneration)
|
|
1926
1988
|
throw unpublishedWhile('camera');
|
|
1989
|
+
// With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
|
|
1990
|
+
// picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
|
|
1991
|
+
const source = this.cameraSource(track);
|
|
1992
|
+
const before = this.camOut;
|
|
1993
|
+
const beforeSource = this.camRaw;
|
|
1994
|
+
const sent = await this.processCamera(source);
|
|
1995
|
+
if (generation !== this.publishGeneration) {
|
|
1996
|
+
if (sent !== source && sent !== before)
|
|
1997
|
+
sent.stop();
|
|
1998
|
+
throw unpublishedWhile('camera');
|
|
1999
|
+
}
|
|
1927
2000
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
1928
2001
|
// off. Setting the track to the camera's state first makes the producer
|
|
1929
2002
|
// start in it, and a camera that is off never sends a frame of the new
|
|
1930
2003
|
// track; the server is told below.
|
|
1931
|
-
|
|
2004
|
+
sent.enabled = !paused;
|
|
1932
2005
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
1933
2006
|
// The layers follow the picture's real size, the project's quality and
|
|
1934
2007
|
// the device, so the browser sends every layer asked for (see
|
|
1935
2008
|
// videoEncodings).
|
|
1936
|
-
const { width = 0, height = 0 } =
|
|
2009
|
+
const { width = 0, height = 0 } = source.getSettings();
|
|
1937
2010
|
// The frame rate first: the size the project allows depends on it.
|
|
1938
|
-
this.cameraFrameRate = this.frameRateFor(
|
|
2011
|
+
this.cameraFrameRate = this.frameRateFor(source);
|
|
1939
2012
|
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1940
2013
|
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1941
2014
|
let codecs;
|
|
@@ -1947,7 +2020,7 @@ class StreamClient {
|
|
|
1947
2020
|
}
|
|
1948
2021
|
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
1949
2022
|
const produce = (codec) => transport.produce({
|
|
1950
|
-
track,
|
|
2023
|
+
track: sent,
|
|
1951
2024
|
stopTracks: false,
|
|
1952
2025
|
encodings,
|
|
1953
2026
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
@@ -1969,6 +2042,13 @@ class StreamClient {
|
|
|
1969
2042
|
// unpublish() may have run while this was being made (a publish, a
|
|
1970
2043
|
// quality change or a track swap under way).
|
|
1971
2044
|
await this.keepProducer(producer, generation, 'camera');
|
|
2045
|
+
if (sent !== track)
|
|
2046
|
+
this.adoptVideoTrack(sent);
|
|
2047
|
+
if (before && before !== sent)
|
|
2048
|
+
before.stop();
|
|
2049
|
+
// A second publish brought another camera: the one the processor drew from before is no longer published.
|
|
2050
|
+
if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
|
|
2051
|
+
beforeSource.stop();
|
|
1972
2052
|
this.watchTrackEnded(producer);
|
|
1973
2053
|
this.syncPublishProtection();
|
|
1974
2054
|
this.cameraEncodings = encodings;
|
|
@@ -2340,7 +2420,8 @@ class StreamClient {
|
|
|
2340
2420
|
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
|
|
2341
2421
|
return;
|
|
2342
2422
|
this.frameRateFallback = true;
|
|
2343
|
-
|
|
2423
|
+
const camera = this.cameraSource(producer.track);
|
|
2424
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
2344
2425
|
await this.reproduceCamera(producer, producer.track);
|
|
2345
2426
|
this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
2346
2427
|
}).catch((error) => this.emit('error', error));
|
|
@@ -2586,8 +2667,10 @@ class StreamClient {
|
|
|
2586
2667
|
layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2587
2668
|
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2588
2669
|
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
2670
|
+
// Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
|
|
2671
|
+
// frame is already the right size without a round trip in front of it (P3-6).
|
|
2589
2672
|
if (layers) {
|
|
2590
|
-
|
|
2673
|
+
void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
2591
2674
|
}
|
|
2592
2675
|
current(consumer);
|
|
2593
2676
|
// The server created it paused. Resume only now that our end exists,
|
|
@@ -2656,6 +2739,130 @@ class StreamClient {
|
|
|
2656
2739
|
return { spatialLayer: adaptive };
|
|
2657
2740
|
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
2658
2741
|
}
|
|
2742
|
+
// ------------------------------------------------------- audio processor
|
|
2743
|
+
/**
|
|
2744
|
+
* Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
|
|
2745
|
+
* suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
|
|
2746
|
+
* published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
|
|
2747
|
+
* new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
|
|
2748
|
+
* keeps the microphone off: it is sent as it is, and `error` says why.
|
|
2749
|
+
*/
|
|
2750
|
+
async setAudioProcessor(processor) {
|
|
2751
|
+
await this.micTask(async () => {
|
|
2752
|
+
const previous = this.audioProcessor;
|
|
2753
|
+
this.audioProcessor = processor;
|
|
2754
|
+
const producer = this.micProducer();
|
|
2755
|
+
const device = producer ? this.deviceTrackOf(producer) : null;
|
|
2756
|
+
if (producer && device && !producer.closed) {
|
|
2757
|
+
const sent = await this.processMic(device);
|
|
2758
|
+
await producer.replaceTrack({ track: sent });
|
|
2759
|
+
this.micRaw = sent !== device ? device : null;
|
|
2760
|
+
}
|
|
2761
|
+
if (previous && previous !== processor)
|
|
2762
|
+
await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
/** The processor in use now, or null. */
|
|
2766
|
+
get audioProcessorInUse() {
|
|
2767
|
+
return this.audioProcessor;
|
|
2768
|
+
}
|
|
2769
|
+
/** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
|
|
2770
|
+
async processMic(track) {
|
|
2771
|
+
const processor = this.audioProcessor;
|
|
2772
|
+
if (!processor)
|
|
2773
|
+
return track;
|
|
2774
|
+
try {
|
|
2775
|
+
return await processor.process(track);
|
|
2776
|
+
}
|
|
2777
|
+
catch (err) {
|
|
2778
|
+
this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
|
|
2779
|
+
return track;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
/** The device's own track behind a producer: for the microphone, the one before any processor. */
|
|
2783
|
+
deviceTrackOf(producer) {
|
|
2784
|
+
const source = producer.appData.source;
|
|
2785
|
+
if (source === 'mic')
|
|
2786
|
+
return this.micRaw ?? producer.track;
|
|
2787
|
+
return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
|
|
2791
|
+
* or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
|
|
2792
|
+
* picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
|
|
2793
|
+
* same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
|
|
2794
|
+
* new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
|
|
2795
|
+
* the camera off: it is sent as it is, and `error` says why.
|
|
2796
|
+
*/
|
|
2797
|
+
async setVideoProcessor(processor) {
|
|
2798
|
+
await this.cameraTask(async () => {
|
|
2799
|
+
const previous = this.videoProcessor;
|
|
2800
|
+
this.videoProcessor = processor;
|
|
2801
|
+
const producer = this.cameraProducer();
|
|
2802
|
+
const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2803
|
+
if (shown && shown.readyState === 'live') {
|
|
2804
|
+
const source = this.cameraSource(shown);
|
|
2805
|
+
const before = this.camOut;
|
|
2806
|
+
const paused = producer ? producer.paused : !shown.enabled;
|
|
2807
|
+
// Made afresh, by the new processor.
|
|
2808
|
+
this.camRaw = null;
|
|
2809
|
+
this.camOut = null;
|
|
2810
|
+
const sent = await this.processCamera(source);
|
|
2811
|
+
sent.enabled = !paused;
|
|
2812
|
+
if (producer && !producer.closed && producer.track !== sent)
|
|
2813
|
+
await producer.replaceTrack({ track: sent });
|
|
2814
|
+
this.adoptVideoTrack(sent);
|
|
2815
|
+
if (before && before !== sent)
|
|
2816
|
+
before.stop();
|
|
2817
|
+
}
|
|
2818
|
+
if (previous && previous !== processor)
|
|
2819
|
+
await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
/** The video processor in use now, or null. */
|
|
2823
|
+
get videoProcessorInUse() {
|
|
2824
|
+
return this.videoProcessor;
|
|
2825
|
+
}
|
|
2826
|
+
/** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
|
|
2827
|
+
cameraSource(track) {
|
|
2828
|
+
return track === this.camOut && this.camRaw ? this.camRaw : track;
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
|
|
2832
|
+
* already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
|
|
2833
|
+
*/
|
|
2834
|
+
async processCamera(source) {
|
|
2835
|
+
const processor = this.videoProcessor;
|
|
2836
|
+
if (!processor) {
|
|
2837
|
+
this.camRaw = null;
|
|
2838
|
+
this.camOut = null;
|
|
2839
|
+
return source;
|
|
2840
|
+
}
|
|
2841
|
+
if (this.camRaw === source && this.camOut?.readyState === 'live')
|
|
2842
|
+
return this.camOut;
|
|
2843
|
+
try {
|
|
2844
|
+
const out = await processor.process(source);
|
|
2845
|
+
// The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
|
|
2846
|
+
source.enabled = true;
|
|
2847
|
+
this.camRaw = source;
|
|
2848
|
+
this.camOut = out;
|
|
2849
|
+
return out;
|
|
2850
|
+
}
|
|
2851
|
+
catch (err) {
|
|
2852
|
+
this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
|
|
2853
|
+
this.camRaw = null;
|
|
2854
|
+
this.camOut = null;
|
|
2855
|
+
return source;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
/** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
|
|
2859
|
+
stopLocalTracks() {
|
|
2860
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
2861
|
+
this.camRaw?.stop();
|
|
2862
|
+
this.camOut?.stop();
|
|
2863
|
+
this.camRaw = null;
|
|
2864
|
+
this.camOut = null;
|
|
2865
|
+
}
|
|
2659
2866
|
// ------------------------------------------------------- diagnostics
|
|
2660
2867
|
/**
|
|
2661
2868
|
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
@@ -2875,8 +3082,10 @@ class StreamClient {
|
|
|
2875
3082
|
try {
|
|
2876
3083
|
const mic = this.micProducer();
|
|
2877
3084
|
const camera = this.cameraProducer();
|
|
2878
|
-
|
|
2879
|
-
const
|
|
3085
|
+
// A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
|
|
3086
|
+
const micLevel = mic && !mic.paused && !this.audioProcessor ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
|
|
3087
|
+
// A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
|
|
3088
|
+
const cameraFps = camera && !camera.paused && !this.videoProcessor ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
|
|
2880
3089
|
if (!this.healthTimer)
|
|
2881
3090
|
return;
|
|
2882
3091
|
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
@@ -2917,7 +3126,9 @@ class StreamClient {
|
|
|
2917
3126
|
if (!md?.getUserMedia)
|
|
2918
3127
|
throw new Error('switchDevice: this browser cannot capture media');
|
|
2919
3128
|
if (kind === 'videoinput') {
|
|
2920
|
-
const
|
|
3129
|
+
const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3130
|
+
// With a video processor, the camera it draws from.
|
|
3131
|
+
const current = shown ? this.cameraSource(shown) : null;
|
|
2921
3132
|
if (!current)
|
|
2922
3133
|
throw new Error('switchDevice: no camera is being published');
|
|
2923
3134
|
if (!(0, devices_js_1.deviceIdOf)(current)) {
|
|
@@ -2957,8 +3168,10 @@ class StreamClient {
|
|
|
2957
3168
|
}
|
|
2958
3169
|
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
2959
3170
|
async adoptAudioTrack(producer, track) {
|
|
2960
|
-
const previous = producer.track;
|
|
2961
|
-
await
|
|
3171
|
+
const previous = this.micRaw ?? producer.track;
|
|
3172
|
+
const sent = await this.processMic(track);
|
|
3173
|
+
await producer.replaceTrack({ track: sent });
|
|
3174
|
+
this.micRaw = sent !== track ? track : null;
|
|
2962
3175
|
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
2963
3176
|
this.localStream = new MediaStream([track, ...video]);
|
|
2964
3177
|
this.emit('localStreamChanged', this.localStream);
|
|
@@ -2968,7 +3181,8 @@ class StreamClient {
|
|
|
2968
3181
|
}
|
|
2969
3182
|
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
2970
3183
|
watchTrackEnded(producer) {
|
|
2971
|
-
|
|
3184
|
+
// The device's track, not the processed one a processor sends: that is the one a device ends.
|
|
3185
|
+
const track = this.deviceTrackOf(producer);
|
|
2972
3186
|
if (!track || typeof track.addEventListener !== 'function')
|
|
2973
3187
|
return;
|
|
2974
3188
|
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
@@ -2977,7 +3191,7 @@ class StreamClient {
|
|
|
2977
3191
|
if (!which)
|
|
2978
3192
|
return;
|
|
2979
3193
|
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
2980
|
-
const current = () => !this.closedByUs && !producer.closed && producer
|
|
3194
|
+
const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
|
|
2981
3195
|
track.addEventListener('mute', () => {
|
|
2982
3196
|
if (!current() || this.interruptions.has(which))
|
|
2983
3197
|
return;
|
|
@@ -3002,7 +3216,7 @@ class StreamClient {
|
|
|
3002
3216
|
* swapped for another camera on its own (Chime stops it the same way).
|
|
3003
3217
|
*/
|
|
3004
3218
|
async onTrackEnded(producer, track) {
|
|
3005
|
-
const live = () => !this.closedByUs && !producer.closed && producer
|
|
3219
|
+
const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
|
|
3006
3220
|
if (!live())
|
|
3007
3221
|
return;
|
|
3008
3222
|
const deviceId = (0, devices_js_1.deviceIdOf)(track);
|
|
@@ -3410,7 +3624,7 @@ class StreamClient {
|
|
|
3410
3624
|
const cleared = this.limitWatch.reset();
|
|
3411
3625
|
if (cleared)
|
|
3412
3626
|
this.emit('videoLimited', cleared);
|
|
3413
|
-
this.
|
|
3627
|
+
this.stopLocalTracks();
|
|
3414
3628
|
this.localStream = null;
|
|
3415
3629
|
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
3416
3630
|
// and microphone again and publish them, after the app unpublished them.
|