@voltkit-rtc/client 0.8.0 → 0.9.0
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 +29 -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/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/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/video-effects.ts +552 -0
package/dist/esm/index.js
CHANGED
|
@@ -202,6 +202,8 @@ export class StreamClient {
|
|
|
202
202
|
// server only remembers the last one, rejecting consumes on the first.
|
|
203
203
|
sendTransportPending = null;
|
|
204
204
|
recvTransportPending = null;
|
|
205
|
+
/** Transports the join made (P3-6), each used once by the first transport of its direction. */
|
|
206
|
+
preparedTransports = null;
|
|
205
207
|
pending = new Map();
|
|
206
208
|
listeners = new Map();
|
|
207
209
|
consumers = new Map();
|
|
@@ -298,6 +300,13 @@ export class StreamClient {
|
|
|
298
300
|
healthTimer = null;
|
|
299
301
|
healthSampling = false;
|
|
300
302
|
interruptions = new Map();
|
|
303
|
+
/** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
|
|
304
|
+
audioProcessor = null;
|
|
305
|
+
micRaw = null;
|
|
306
|
+
videoProcessor = null;
|
|
307
|
+
/** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
|
|
308
|
+
camRaw = null;
|
|
309
|
+
camOut = null;
|
|
301
310
|
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
302
311
|
diagnosticsLog = new DiagnosticsLog();
|
|
303
312
|
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
@@ -555,8 +564,13 @@ export class StreamClient {
|
|
|
555
564
|
const opening = this.openSocket();
|
|
556
565
|
socket = this.ws;
|
|
557
566
|
await opening;
|
|
558
|
-
|
|
567
|
+
// The transports come with the join where the server can make them (P3-6): the first publish and the first
|
|
568
|
+
// video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
|
|
569
|
+
const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] }));
|
|
570
|
+
const { transports, ...joinResult } = joined;
|
|
571
|
+
result = joinResult;
|
|
559
572
|
this.joinResult = result;
|
|
573
|
+
this.preparedTransports = transports ?? null;
|
|
560
574
|
this.device = this.createDevice();
|
|
561
575
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
562
576
|
// close() ran while the Device loaded: going on would set the state
|
|
@@ -813,6 +827,14 @@ export class StreamClient {
|
|
|
813
827
|
}
|
|
814
828
|
close() {
|
|
815
829
|
void this.stopRecording().catch(() => undefined);
|
|
830
|
+
// The processor's audio graph goes with the session.
|
|
831
|
+
const processor = this.audioProcessor;
|
|
832
|
+
this.audioProcessor = null;
|
|
833
|
+
this.micRaw = null;
|
|
834
|
+
void Promise.resolve(processor?.destroy()).catch(() => undefined);
|
|
835
|
+
const videoProcessor = this.videoProcessor;
|
|
836
|
+
this.videoProcessor = null;
|
|
837
|
+
void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
|
|
816
838
|
// Adaptive stream stops watching elements: nothing is received any more.
|
|
817
839
|
this.adaptive.dispose();
|
|
818
840
|
this.adaptivePaused.clear();
|
|
@@ -835,7 +857,7 @@ export class StreamClient {
|
|
|
835
857
|
this.skippedVideos.clear();
|
|
836
858
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
837
859
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
838
|
-
this.
|
|
860
|
+
this.stopLocalTracks();
|
|
839
861
|
this.localStream = null;
|
|
840
862
|
const ws = this.ws;
|
|
841
863
|
this.ws = null;
|
|
@@ -849,6 +871,9 @@ export class StreamClient {
|
|
|
849
871
|
this.emit('closed', 'left');
|
|
850
872
|
}
|
|
851
873
|
teardownMedia() {
|
|
874
|
+
this.micRaw = null;
|
|
875
|
+
// Made for the session that is going; a rejoin brings new ones.
|
|
876
|
+
this.preparedTransports = null;
|
|
852
877
|
this.mediaPaths.reset();
|
|
853
878
|
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
854
879
|
// app's "poor connection" overlay does not outlive it.
|
|
@@ -1332,18 +1357,14 @@ export class StreamClient {
|
|
|
1332
1357
|
throw new RpcError('not-connected', 'not connected');
|
|
1333
1358
|
const device = this.device;
|
|
1334
1359
|
const socket = this.ws;
|
|
1335
|
-
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
1360
|
+
const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' }));
|
|
1336
1361
|
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1337
1362
|
throw closedWhileConnecting();
|
|
1338
1363
|
const transport = device.createSendTransport({
|
|
1339
1364
|
...params,
|
|
1340
1365
|
iceServers: this.joinResult?.iceServers,
|
|
1341
1366
|
});
|
|
1342
|
-
|
|
1343
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1344
|
-
.then(() => ok())
|
|
1345
|
-
.catch(fail);
|
|
1346
|
-
});
|
|
1367
|
+
this.connectEarly(transport);
|
|
1347
1368
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
1348
1369
|
this.rpc('produce', {
|
|
1349
1370
|
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
@@ -1356,6 +1377,33 @@ export class StreamClient {
|
|
|
1356
1377
|
this.watchTransport(transport);
|
|
1357
1378
|
return transport;
|
|
1358
1379
|
}
|
|
1380
|
+
/**
|
|
1381
|
+
* A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
|
|
1382
|
+
* remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
|
|
1383
|
+
* trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
|
|
1384
|
+
* them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
|
|
1385
|
+
* role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
|
|
1386
|
+
* sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
|
|
1387
|
+
* straight after. A refusal means the transport can never carry media: the session rejoins.
|
|
1388
|
+
*/
|
|
1389
|
+
connectEarly(transport) {
|
|
1390
|
+
transport.on('connect', ({ dtlsParameters }, ok) => {
|
|
1391
|
+
ok();
|
|
1392
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
|
|
1393
|
+
if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport))
|
|
1394
|
+
return;
|
|
1395
|
+
this.emit('error', err);
|
|
1396
|
+
this.recoverConnection(`connectTransport failed: ${err?.message ?? err}`);
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
/** The transport the join made for this direction, once: a later one is created as before. */
|
|
1401
|
+
takePreparedTransport(direction) {
|
|
1402
|
+
const prepared = this.preparedTransports?.[direction] ?? null;
|
|
1403
|
+
if (this.preparedTransports)
|
|
1404
|
+
this.preparedTransports[direction] = undefined;
|
|
1405
|
+
return prepared;
|
|
1406
|
+
}
|
|
1359
1407
|
ensureRecvTransport() {
|
|
1360
1408
|
if (this.recvTransport)
|
|
1361
1409
|
return Promise.resolve(this.recvTransport);
|
|
@@ -1372,18 +1420,14 @@ export class StreamClient {
|
|
|
1372
1420
|
throw new RpcError('not-connected', 'not connected');
|
|
1373
1421
|
const device = this.device;
|
|
1374
1422
|
const socket = this.ws;
|
|
1375
|
-
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1423
|
+
const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1376
1424
|
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1377
1425
|
throw closedWhileConnecting();
|
|
1378
1426
|
const transport = device.createRecvTransport({
|
|
1379
1427
|
...params,
|
|
1380
1428
|
iceServers: this.joinResult?.iceServers,
|
|
1381
1429
|
});
|
|
1382
|
-
|
|
1383
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1384
|
-
.then(() => ok())
|
|
1385
|
-
.catch(fail);
|
|
1386
|
-
});
|
|
1430
|
+
this.connectEarly(transport);
|
|
1387
1431
|
this.recvTransport = transport;
|
|
1388
1432
|
this.watchTransport(transport);
|
|
1389
1433
|
return transport;
|
|
@@ -1496,7 +1540,8 @@ export class StreamClient {
|
|
|
1496
1540
|
this.audioCapReported = false;
|
|
1497
1541
|
await this.micTask(async () => {
|
|
1498
1542
|
const current = this.micProducer();
|
|
1499
|
-
|
|
1543
|
+
// The device's track: the processed one is the old processor graph's, let go when it is processed again.
|
|
1544
|
+
const track = current ? this.deviceTrackOf(current) : null;
|
|
1500
1545
|
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1501
1546
|
return;
|
|
1502
1547
|
await this.reproduceMic(current, track);
|
|
@@ -1537,8 +1582,10 @@ export class StreamClient {
|
|
|
1537
1582
|
throw unpublishedWhile('microphone');
|
|
1538
1583
|
const quality = this.audioQualityFor();
|
|
1539
1584
|
const settings = AUDIO_SETTINGS[quality];
|
|
1585
|
+
// Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
|
|
1586
|
+
const sent = await this.processMic(track);
|
|
1540
1587
|
const producer = await transport.produce({
|
|
1541
|
-
track,
|
|
1588
|
+
track: sent,
|
|
1542
1589
|
// Keep the track alive when the producer closes: a reconnect closes
|
|
1543
1590
|
// every producer, and a stopped track cannot be republished.
|
|
1544
1591
|
stopTracks: false,
|
|
@@ -1551,6 +1598,7 @@ export class StreamClient {
|
|
|
1551
1598
|
appData: { source: 'mic', audioQuality: quality },
|
|
1552
1599
|
});
|
|
1553
1600
|
await this.keepProducer(producer, generation, 'microphone');
|
|
1601
|
+
this.micRaw = sent !== track ? track : null;
|
|
1554
1602
|
this.watchTrackEnded(producer);
|
|
1555
1603
|
this.micQuality = quality;
|
|
1556
1604
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
@@ -1623,8 +1671,9 @@ export class StreamClient {
|
|
|
1623
1671
|
const track = current?.track;
|
|
1624
1672
|
if (!current || !track || track.readyState !== 'live')
|
|
1625
1673
|
return;
|
|
1674
|
+
const camera = this.cameraSource(track);
|
|
1626
1675
|
try {
|
|
1627
|
-
await
|
|
1676
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
|
|
1628
1677
|
}
|
|
1629
1678
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
1630
1679
|
await this.reproduceCamera(current, track);
|
|
@@ -1674,43 +1723,56 @@ export class StreamClient {
|
|
|
1674
1723
|
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
1675
1724
|
if (!current && !reconnecting)
|
|
1676
1725
|
throw new Error('replaceVideoTrack: no camera is being published');
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
if (replaced && replaced !== track) {
|
|
1684
|
-
track.enabled = replaced.enabled;
|
|
1685
|
-
replaced.enabled = true;
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
this.adoptVideoTrack(track);
|
|
1689
|
-
if (!current || current.track === track)
|
|
1690
|
-
return;
|
|
1691
|
-
const previous = current.track;
|
|
1692
|
-
const paused = current.paused;
|
|
1726
|
+
// The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
|
|
1727
|
+
// processor too, when there is one.
|
|
1728
|
+
const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
1729
|
+
const previousSource = shown ? this.cameraSource(shown) : null;
|
|
1730
|
+
const before = this.camOut;
|
|
1731
|
+
const sent = await this.processCamera(track);
|
|
1693
1732
|
try {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1733
|
+
if (!current) {
|
|
1734
|
+
// The republish reads the camera's state off the track it sends, and
|
|
1735
|
+
// the track being replaced is switched off exactly when the camera is
|
|
1736
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
1737
|
+
// and the old track back switched on, as below.
|
|
1738
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
1739
|
+
if (replaced && replaced !== sent) {
|
|
1740
|
+
sent.enabled = replaced.enabled;
|
|
1741
|
+
replaced.enabled = true;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
this.adoptVideoTrack(sent);
|
|
1745
|
+
if (!current || current.track === sent)
|
|
1703
1746
|
return;
|
|
1747
|
+
const previous = current.track;
|
|
1748
|
+
const paused = current.paused;
|
|
1749
|
+
try {
|
|
1750
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
1751
|
+
const frameRate = this.frameRateFor(track);
|
|
1752
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
1753
|
+
const previousSize = previousSource?.getSettings();
|
|
1754
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1755
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
1756
|
+
await current.replaceTrack({ track: sent });
|
|
1757
|
+
this.watchTrackEnded(current);
|
|
1758
|
+
await this.applyLayerBitrates(current, next);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
await this.reproduceCamera(current, sent);
|
|
1762
|
+
}
|
|
1763
|
+
finally {
|
|
1764
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
1765
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
1766
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
1767
|
+
// off, that canvas stays black after the camera is resumed.
|
|
1768
|
+
if (paused && previous && previous !== this.cameraProducer()?.track)
|
|
1769
|
+
previous.enabled = true;
|
|
1704
1770
|
}
|
|
1705
|
-
await this.reproduceCamera(current, track);
|
|
1706
1771
|
}
|
|
1707
1772
|
finally {
|
|
1708
|
-
//
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
// off, that canvas stays black after the camera is resumed.
|
|
1712
|
-
if (paused && previous && previous !== this.cameraProducer()?.track)
|
|
1713
|
-
previous.enabled = true;
|
|
1773
|
+
// The processor's previous picture is no longer sent or shown.
|
|
1774
|
+
if (before && before !== this.camOut)
|
|
1775
|
+
before.stop();
|
|
1714
1776
|
}
|
|
1715
1777
|
});
|
|
1716
1778
|
}
|
|
@@ -1891,18 +1953,29 @@ export class StreamClient {
|
|
|
1891
1953
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
1892
1954
|
if (generation !== this.publishGeneration)
|
|
1893
1955
|
throw unpublishedWhile('camera');
|
|
1956
|
+
// With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
|
|
1957
|
+
// picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
|
|
1958
|
+
const source = this.cameraSource(track);
|
|
1959
|
+
const before = this.camOut;
|
|
1960
|
+
const beforeSource = this.camRaw;
|
|
1961
|
+
const sent = await this.processCamera(source);
|
|
1962
|
+
if (generation !== this.publishGeneration) {
|
|
1963
|
+
if (sent !== source && sent !== before)
|
|
1964
|
+
sent.stop();
|
|
1965
|
+
throw unpublishedWhile('camera');
|
|
1966
|
+
}
|
|
1894
1967
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
1895
1968
|
// off. Setting the track to the camera's state first makes the producer
|
|
1896
1969
|
// start in it, and a camera that is off never sends a frame of the new
|
|
1897
1970
|
// track; the server is told below.
|
|
1898
|
-
|
|
1971
|
+
sent.enabled = !paused;
|
|
1899
1972
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
1900
1973
|
// The layers follow the picture's real size, the project's quality and
|
|
1901
1974
|
// the device, so the browser sends every layer asked for (see
|
|
1902
1975
|
// videoEncodings).
|
|
1903
|
-
const { width = 0, height = 0 } =
|
|
1976
|
+
const { width = 0, height = 0 } = source.getSettings();
|
|
1904
1977
|
// The frame rate first: the size the project allows depends on it.
|
|
1905
|
-
this.cameraFrameRate = this.frameRateFor(
|
|
1978
|
+
this.cameraFrameRate = this.frameRateFor(source);
|
|
1906
1979
|
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1907
1980
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1908
1981
|
let codecs;
|
|
@@ -1914,7 +1987,7 @@ export class StreamClient {
|
|
|
1914
1987
|
}
|
|
1915
1988
|
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
1916
1989
|
const produce = (codec) => transport.produce({
|
|
1917
|
-
track,
|
|
1990
|
+
track: sent,
|
|
1918
1991
|
stopTracks: false,
|
|
1919
1992
|
encodings,
|
|
1920
1993
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
@@ -1936,6 +2009,13 @@ export class StreamClient {
|
|
|
1936
2009
|
// unpublish() may have run while this was being made (a publish, a
|
|
1937
2010
|
// quality change or a track swap under way).
|
|
1938
2011
|
await this.keepProducer(producer, generation, 'camera');
|
|
2012
|
+
if (sent !== track)
|
|
2013
|
+
this.adoptVideoTrack(sent);
|
|
2014
|
+
if (before && before !== sent)
|
|
2015
|
+
before.stop();
|
|
2016
|
+
// A second publish brought another camera: the one the processor drew from before is no longer published.
|
|
2017
|
+
if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw)
|
|
2018
|
+
beforeSource.stop();
|
|
1939
2019
|
this.watchTrackEnded(producer);
|
|
1940
2020
|
this.syncPublishProtection();
|
|
1941
2021
|
this.cameraEncodings = encodings;
|
|
@@ -2307,7 +2387,8 @@ export class StreamClient {
|
|
|
2307
2387
|
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
|
|
2308
2388
|
return;
|
|
2309
2389
|
this.frameRateFallback = true;
|
|
2310
|
-
|
|
2390
|
+
const camera = this.cameraSource(producer.track);
|
|
2391
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
2311
2392
|
await this.reproduceCamera(producer, producer.track);
|
|
2312
2393
|
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
2313
2394
|
}).catch((error) => this.emit('error', error));
|
|
@@ -2553,8 +2634,10 @@ export class StreamClient {
|
|
|
2553
2634
|
layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2554
2635
|
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2555
2636
|
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
2637
|
+
// Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
|
|
2638
|
+
// frame is already the right size without a round trip in front of it (P3-6).
|
|
2556
2639
|
if (layers) {
|
|
2557
|
-
|
|
2640
|
+
void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
2558
2641
|
}
|
|
2559
2642
|
current(consumer);
|
|
2560
2643
|
// The server created it paused. Resume only now that our end exists,
|
|
@@ -2623,6 +2706,130 @@ export class StreamClient {
|
|
|
2623
2706
|
return { spatialLayer: adaptive };
|
|
2624
2707
|
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
2625
2708
|
}
|
|
2709
|
+
// ------------------------------------------------------- audio processor
|
|
2710
|
+
/**
|
|
2711
|
+
* Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
|
|
2712
|
+
* suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
|
|
2713
|
+
* published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
|
|
2714
|
+
* new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
|
|
2715
|
+
* keeps the microphone off: it is sent as it is, and `error` says why.
|
|
2716
|
+
*/
|
|
2717
|
+
async setAudioProcessor(processor) {
|
|
2718
|
+
await this.micTask(async () => {
|
|
2719
|
+
const previous = this.audioProcessor;
|
|
2720
|
+
this.audioProcessor = processor;
|
|
2721
|
+
const producer = this.micProducer();
|
|
2722
|
+
const device = producer ? this.deviceTrackOf(producer) : null;
|
|
2723
|
+
if (producer && device && !producer.closed) {
|
|
2724
|
+
const sent = await this.processMic(device);
|
|
2725
|
+
await producer.replaceTrack({ track: sent });
|
|
2726
|
+
this.micRaw = sent !== device ? device : null;
|
|
2727
|
+
}
|
|
2728
|
+
if (previous && previous !== processor)
|
|
2729
|
+
await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
/** The processor in use now, or null. */
|
|
2733
|
+
get audioProcessorInUse() {
|
|
2734
|
+
return this.audioProcessor;
|
|
2735
|
+
}
|
|
2736
|
+
/** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
|
|
2737
|
+
async processMic(track) {
|
|
2738
|
+
const processor = this.audioProcessor;
|
|
2739
|
+
if (!processor)
|
|
2740
|
+
return track;
|
|
2741
|
+
try {
|
|
2742
|
+
return await processor.process(track);
|
|
2743
|
+
}
|
|
2744
|
+
catch (err) {
|
|
2745
|
+
this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${err?.message ?? err}`));
|
|
2746
|
+
return track;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
/** The device's own track behind a producer: for the microphone, the one before any processor. */
|
|
2750
|
+
deviceTrackOf(producer) {
|
|
2751
|
+
const source = producer.appData.source;
|
|
2752
|
+
if (source === 'mic')
|
|
2753
|
+
return this.micRaw ?? producer.track;
|
|
2754
|
+
return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
|
|
2758
|
+
* or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
|
|
2759
|
+
* picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
|
|
2760
|
+
* same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
|
|
2761
|
+
* new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
|
|
2762
|
+
* the camera off: it is sent as it is, and `error` says why.
|
|
2763
|
+
*/
|
|
2764
|
+
async setVideoProcessor(processor) {
|
|
2765
|
+
await this.cameraTask(async () => {
|
|
2766
|
+
const previous = this.videoProcessor;
|
|
2767
|
+
this.videoProcessor = processor;
|
|
2768
|
+
const producer = this.cameraProducer();
|
|
2769
|
+
const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2770
|
+
if (shown && shown.readyState === 'live') {
|
|
2771
|
+
const source = this.cameraSource(shown);
|
|
2772
|
+
const before = this.camOut;
|
|
2773
|
+
const paused = producer ? producer.paused : !shown.enabled;
|
|
2774
|
+
// Made afresh, by the new processor.
|
|
2775
|
+
this.camRaw = null;
|
|
2776
|
+
this.camOut = null;
|
|
2777
|
+
const sent = await this.processCamera(source);
|
|
2778
|
+
sent.enabled = !paused;
|
|
2779
|
+
if (producer && !producer.closed && producer.track !== sent)
|
|
2780
|
+
await producer.replaceTrack({ track: sent });
|
|
2781
|
+
this.adoptVideoTrack(sent);
|
|
2782
|
+
if (before && before !== sent)
|
|
2783
|
+
before.stop();
|
|
2784
|
+
}
|
|
2785
|
+
if (previous && previous !== processor)
|
|
2786
|
+
await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
/** The video processor in use now, or null. */
|
|
2790
|
+
get videoProcessorInUse() {
|
|
2791
|
+
return this.videoProcessor;
|
|
2792
|
+
}
|
|
2793
|
+
/** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
|
|
2794
|
+
cameraSource(track) {
|
|
2795
|
+
return track === this.camOut && this.camRaw ? this.camRaw : track;
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
|
|
2799
|
+
* already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
|
|
2800
|
+
*/
|
|
2801
|
+
async processCamera(source) {
|
|
2802
|
+
const processor = this.videoProcessor;
|
|
2803
|
+
if (!processor) {
|
|
2804
|
+
this.camRaw = null;
|
|
2805
|
+
this.camOut = null;
|
|
2806
|
+
return source;
|
|
2807
|
+
}
|
|
2808
|
+
if (this.camRaw === source && this.camOut?.readyState === 'live')
|
|
2809
|
+
return this.camOut;
|
|
2810
|
+
try {
|
|
2811
|
+
const out = await processor.process(source);
|
|
2812
|
+
// The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
|
|
2813
|
+
source.enabled = true;
|
|
2814
|
+
this.camRaw = source;
|
|
2815
|
+
this.camOut = out;
|
|
2816
|
+
return out;
|
|
2817
|
+
}
|
|
2818
|
+
catch (err) {
|
|
2819
|
+
this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${err?.message ?? err}`));
|
|
2820
|
+
this.camRaw = null;
|
|
2821
|
+
this.camOut = null;
|
|
2822
|
+
return source;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
/** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
|
|
2826
|
+
stopLocalTracks() {
|
|
2827
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
2828
|
+
this.camRaw?.stop();
|
|
2829
|
+
this.camOut?.stop();
|
|
2830
|
+
this.camRaw = null;
|
|
2831
|
+
this.camOut = null;
|
|
2832
|
+
}
|
|
2626
2833
|
// ------------------------------------------------------- diagnostics
|
|
2627
2834
|
/**
|
|
2628
2835
|
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
@@ -2842,8 +3049,10 @@ export class StreamClient {
|
|
|
2842
3049
|
try {
|
|
2843
3050
|
const mic = this.micProducer();
|
|
2844
3051
|
const camera = this.cameraProducer();
|
|
2845
|
-
|
|
2846
|
-
const
|
|
3052
|
+
// A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
|
|
3053
|
+
const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values()).level : null;
|
|
3054
|
+
// A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
|
|
3055
|
+
const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values()).fps : null;
|
|
2847
3056
|
if (!this.healthTimer)
|
|
2848
3057
|
return;
|
|
2849
3058
|
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
@@ -2884,7 +3093,9 @@ export class StreamClient {
|
|
|
2884
3093
|
if (!md?.getUserMedia)
|
|
2885
3094
|
throw new Error('switchDevice: this browser cannot capture media');
|
|
2886
3095
|
if (kind === 'videoinput') {
|
|
2887
|
-
const
|
|
3096
|
+
const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3097
|
+
// With a video processor, the camera it draws from.
|
|
3098
|
+
const current = shown ? this.cameraSource(shown) : null;
|
|
2888
3099
|
if (!current)
|
|
2889
3100
|
throw new Error('switchDevice: no camera is being published');
|
|
2890
3101
|
if (!deviceIdOf(current)) {
|
|
@@ -2924,8 +3135,10 @@ export class StreamClient {
|
|
|
2924
3135
|
}
|
|
2925
3136
|
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
2926
3137
|
async adoptAudioTrack(producer, track) {
|
|
2927
|
-
const previous = producer.track;
|
|
2928
|
-
await
|
|
3138
|
+
const previous = this.micRaw ?? producer.track;
|
|
3139
|
+
const sent = await this.processMic(track);
|
|
3140
|
+
await producer.replaceTrack({ track: sent });
|
|
3141
|
+
this.micRaw = sent !== track ? track : null;
|
|
2929
3142
|
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
2930
3143
|
this.localStream = new MediaStream([track, ...video]);
|
|
2931
3144
|
this.emit('localStreamChanged', this.localStream);
|
|
@@ -2935,7 +3148,8 @@ export class StreamClient {
|
|
|
2935
3148
|
}
|
|
2936
3149
|
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
2937
3150
|
watchTrackEnded(producer) {
|
|
2938
|
-
|
|
3151
|
+
// The device's track, not the processed one a processor sends: that is the one a device ends.
|
|
3152
|
+
const track = this.deviceTrackOf(producer);
|
|
2939
3153
|
if (!track || typeof track.addEventListener !== 'function')
|
|
2940
3154
|
return;
|
|
2941
3155
|
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
@@ -2944,7 +3158,7 @@ export class StreamClient {
|
|
|
2944
3158
|
if (!which)
|
|
2945
3159
|
return;
|
|
2946
3160
|
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
2947
|
-
const current = () => !this.closedByUs && !producer.closed && producer
|
|
3161
|
+
const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
|
|
2948
3162
|
track.addEventListener('mute', () => {
|
|
2949
3163
|
if (!current() || this.interruptions.has(which))
|
|
2950
3164
|
return;
|
|
@@ -2969,7 +3183,7 @@ export class StreamClient {
|
|
|
2969
3183
|
* swapped for another camera on its own (Chime stops it the same way).
|
|
2970
3184
|
*/
|
|
2971
3185
|
async onTrackEnded(producer, track) {
|
|
2972
|
-
const live = () => !this.closedByUs && !producer.closed && producer
|
|
3186
|
+
const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
|
|
2973
3187
|
if (!live())
|
|
2974
3188
|
return;
|
|
2975
3189
|
const deviceId = deviceIdOf(track);
|
|
@@ -3377,7 +3591,7 @@ export class StreamClient {
|
|
|
3377
3591
|
const cleared = this.limitWatch.reset();
|
|
3378
3592
|
if (cleared)
|
|
3379
3593
|
this.emit('videoLimited', cleared);
|
|
3380
|
-
this.
|
|
3594
|
+
this.stopLocalTracks();
|
|
3381
3595
|
this.localStream = null;
|
|
3382
3596
|
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
3383
3597
|
// and microphone again and publish them, after the app unpublished them.
|