@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/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ type RtpCapabilities = ms.RtpCapabilities;
|
|
|
17
17
|
type RtpCodecCapability = ms.RtpCodecCapability;
|
|
18
18
|
type Transport = ms.Transport;
|
|
19
19
|
type TransportOptions = ms.TransportOptions;
|
|
20
|
+
/** The transports a join made when asked (`join {transports}`, P3-6): exactly what createTransport returns. */
|
|
21
|
+
type PreparedTransports = { send?: TransportOptions; recv?: TransportOptions };
|
|
20
22
|
|
|
21
23
|
import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
|
|
22
24
|
import { isTerminalError, RpcError } from './errors.js';
|
|
@@ -36,6 +38,7 @@ import { AdaptiveVideos, pageIsVisible, type AdaptiveWant } from './adaptive.js'
|
|
|
36
38
|
import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
|
|
37
39
|
import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
|
|
38
40
|
import { connectionPath, runPreflight, syntheticMedia, type PreflightOptions, type PreflightReport } from './preflight.js';
|
|
41
|
+
import type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
|
|
39
42
|
import { DiagnosticsLog, SDK_VERSION, type Diagnostics, type DiagnosticsTransport } from './diagnostics.js';
|
|
40
43
|
import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource, type MediaWarning } from './health.js';
|
|
41
44
|
import { constraintsFor, deviceIdOf, diffDevices, enumerate, type DeviceKind, type DevicesChangedEvent, type MediaDevice } from './devices.js';
|
|
@@ -102,6 +105,7 @@ export { SPEAKING_LEVEL } from './speakers.js';
|
|
|
102
105
|
export type { MediaWarning, MediaWarningCode } from './health.js';
|
|
103
106
|
export type { PreflightOptions, PreflightReport, PreflightStep, PreflightStepName } from './preflight.js';
|
|
104
107
|
export { SDK_VERSION } from './diagnostics.js';
|
|
108
|
+
export type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
|
|
105
109
|
export type { Diagnostics, DiagnosticsEntry, DiagnosticsTransport } from './diagnostics.js';
|
|
106
110
|
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
107
111
|
export type {
|
|
@@ -678,6 +682,8 @@ export class StreamClient {
|
|
|
678
682
|
// server only remembers the last one, rejecting consumes on the first.
|
|
679
683
|
private sendTransportPending: Promise<Transport> | null = null;
|
|
680
684
|
private recvTransportPending: Promise<Transport> | null = null;
|
|
685
|
+
/** Transports the join made (P3-6), each used once by the first transport of its direction. */
|
|
686
|
+
private preparedTransports: PreparedTransports | null = null;
|
|
681
687
|
private readonly pending = new Map<number, Pending>();
|
|
682
688
|
private readonly listeners = new Map<string, Set<Handler>>();
|
|
683
689
|
private readonly consumers = new Map<string, Consumer>();
|
|
@@ -774,6 +780,13 @@ export class StreamClient {
|
|
|
774
780
|
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
775
781
|
private healthSampling = false;
|
|
776
782
|
private readonly interruptions = new Map<'camera' | 'mic', ReturnType<typeof setTimeout>>();
|
|
783
|
+
/** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
|
|
784
|
+
private audioProcessor: AudioTrackProcessor | null = null;
|
|
785
|
+
private micRaw: MediaStreamTrack | null = null;
|
|
786
|
+
private videoProcessor: VideoTrackProcessor | null = null;
|
|
787
|
+
/** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
|
|
788
|
+
private camRaw: MediaStreamTrack | null = null;
|
|
789
|
+
private camOut: MediaStreamTrack | null = null;
|
|
777
790
|
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
778
791
|
private readonly diagnosticsLog = new DiagnosticsLog();
|
|
779
792
|
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
@@ -1033,8 +1046,13 @@ export class StreamClient {
|
|
|
1033
1046
|
const opening = this.openSocket();
|
|
1034
1047
|
socket = this.ws;
|
|
1035
1048
|
await opening;
|
|
1036
|
-
|
|
1049
|
+
// The transports come with the join where the server can make them (P3-6): the first publish and the first
|
|
1050
|
+
// video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
|
|
1051
|
+
const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] })) as JoinResult & { transports?: PreparedTransports };
|
|
1052
|
+
const { transports, ...joinResult } = joined;
|
|
1053
|
+
result = joinResult;
|
|
1037
1054
|
this.joinResult = result;
|
|
1055
|
+
this.preparedTransports = transports ?? null;
|
|
1038
1056
|
|
|
1039
1057
|
this.device = this.createDevice();
|
|
1040
1058
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
@@ -1283,6 +1301,14 @@ export class StreamClient {
|
|
|
1283
1301
|
|
|
1284
1302
|
close(): void {
|
|
1285
1303
|
void this.stopRecording().catch(() => undefined);
|
|
1304
|
+
// The processor's audio graph goes with the session.
|
|
1305
|
+
const processor = this.audioProcessor;
|
|
1306
|
+
this.audioProcessor = null;
|
|
1307
|
+
this.micRaw = null;
|
|
1308
|
+
void Promise.resolve(processor?.destroy()).catch(() => undefined);
|
|
1309
|
+
const videoProcessor = this.videoProcessor;
|
|
1310
|
+
this.videoProcessor = null;
|
|
1311
|
+
void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
|
|
1286
1312
|
// Adaptive stream stops watching elements: nothing is received any more.
|
|
1287
1313
|
this.adaptive.dispose();
|
|
1288
1314
|
this.adaptivePaused.clear();
|
|
@@ -1305,7 +1331,7 @@ export class StreamClient {
|
|
|
1305
1331
|
this.skippedVideos.clear();
|
|
1306
1332
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
1307
1333
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
1308
|
-
this.
|
|
1334
|
+
this.stopLocalTracks();
|
|
1309
1335
|
this.localStream = null;
|
|
1310
1336
|
const ws = this.ws;
|
|
1311
1337
|
this.ws = null;
|
|
@@ -1319,6 +1345,9 @@ export class StreamClient {
|
|
|
1319
1345
|
}
|
|
1320
1346
|
|
|
1321
1347
|
private teardownMedia(): void {
|
|
1348
|
+
this.micRaw = null;
|
|
1349
|
+
// Made for the session that is going; a rejoin brings new ones.
|
|
1350
|
+
this.preparedTransports = null;
|
|
1322
1351
|
this.mediaPaths.reset();
|
|
1323
1352
|
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
1324
1353
|
// app's "poor connection" overlay does not outlive it.
|
|
@@ -1788,18 +1817,14 @@ export class StreamClient {
|
|
|
1788
1817
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1789
1818
|
const device = this.device;
|
|
1790
1819
|
const socket = this.ws;
|
|
1791
|
-
const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
1820
|
+
const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
1792
1821
|
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1793
1822
|
const transport = device.createSendTransport({
|
|
1794
1823
|
...params,
|
|
1795
1824
|
iceServers: this.joinResult?.iceServers,
|
|
1796
1825
|
});
|
|
1797
1826
|
|
|
1798
|
-
|
|
1799
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1800
|
-
.then(() => ok())
|
|
1801
|
-
.catch(fail);
|
|
1802
|
-
});
|
|
1827
|
+
this.connectEarly(transport);
|
|
1803
1828
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
1804
1829
|
this.rpc('produce', {
|
|
1805
1830
|
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
@@ -1814,6 +1839,33 @@ export class StreamClient {
|
|
|
1814
1839
|
return transport;
|
|
1815
1840
|
}
|
|
1816
1841
|
|
|
1842
|
+
/**
|
|
1843
|
+
* A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
|
|
1844
|
+
* remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
|
|
1845
|
+
* trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
|
|
1846
|
+
* them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
|
|
1847
|
+
* role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
|
|
1848
|
+
* sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
|
|
1849
|
+
* straight after. A refusal means the transport can never carry media: the session rejoins.
|
|
1850
|
+
*/
|
|
1851
|
+
private connectEarly(transport: Transport): void {
|
|
1852
|
+
transport.on('connect', ({ dtlsParameters }, ok) => {
|
|
1853
|
+
ok();
|
|
1854
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
|
|
1855
|
+
if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport)) return;
|
|
1856
|
+
this.emit('error', err as Error);
|
|
1857
|
+
this.recoverConnection(`connectTransport failed: ${(err as Error)?.message ?? err}`);
|
|
1858
|
+
});
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** The transport the join made for this direction, once: a later one is created as before. */
|
|
1863
|
+
private takePreparedTransport(direction: 'send' | 'recv'): TransportOptions | null {
|
|
1864
|
+
const prepared = this.preparedTransports?.[direction] ?? null;
|
|
1865
|
+
if (this.preparedTransports) this.preparedTransports[direction] = undefined;
|
|
1866
|
+
return prepared;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1817
1869
|
private ensureRecvTransport(): Promise<Transport> {
|
|
1818
1870
|
if (this.recvTransport) return Promise.resolve(this.recvTransport);
|
|
1819
1871
|
if (this.recvTransportPending) return this.recvTransportPending;
|
|
@@ -1827,17 +1879,13 @@ export class StreamClient {
|
|
|
1827
1879
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1828
1880
|
const device = this.device;
|
|
1829
1881
|
const socket = this.ws;
|
|
1830
|
-
const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
1882
|
+
const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
1831
1883
|
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1832
1884
|
const transport = device.createRecvTransport({
|
|
1833
1885
|
...params,
|
|
1834
1886
|
iceServers: this.joinResult?.iceServers,
|
|
1835
1887
|
});
|
|
1836
|
-
|
|
1837
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1838
|
-
.then(() => ok())
|
|
1839
|
-
.catch(fail);
|
|
1840
|
-
});
|
|
1888
|
+
this.connectEarly(transport);
|
|
1841
1889
|
|
|
1842
1890
|
this.recvTransport = transport;
|
|
1843
1891
|
this.watchTransport(transport);
|
|
@@ -1951,7 +1999,8 @@ export class StreamClient {
|
|
|
1951
1999
|
this.audioCapReported = false;
|
|
1952
2000
|
await this.micTask(async () => {
|
|
1953
2001
|
const current = this.micProducer();
|
|
1954
|
-
|
|
2002
|
+
// The device's track: the processed one is the old processor graph's, let go when it is processed again.
|
|
2003
|
+
const track = current ? this.deviceTrackOf(current) : null;
|
|
1955
2004
|
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
|
|
1956
2005
|
await this.reproduceMic(current, track);
|
|
1957
2006
|
});
|
|
@@ -1991,8 +2040,10 @@ export class StreamClient {
|
|
|
1991
2040
|
if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
|
|
1992
2041
|
const quality = this.audioQualityFor();
|
|
1993
2042
|
const settings = AUDIO_SETTINGS[quality];
|
|
2043
|
+
// Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
|
|
2044
|
+
const sent = await this.processMic(track);
|
|
1994
2045
|
const producer = await transport.produce({
|
|
1995
|
-
track,
|
|
2046
|
+
track: sent,
|
|
1996
2047
|
// Keep the track alive when the producer closes: a reconnect closes
|
|
1997
2048
|
// every producer, and a stopped track cannot be republished.
|
|
1998
2049
|
stopTracks: false,
|
|
@@ -2005,6 +2056,7 @@ export class StreamClient {
|
|
|
2005
2056
|
appData: { source: 'mic', audioQuality: quality },
|
|
2006
2057
|
});
|
|
2007
2058
|
await this.keepProducer(producer, generation, 'microphone');
|
|
2059
|
+
this.micRaw = sent !== track ? track : null;
|
|
2008
2060
|
this.watchTrackEnded(producer);
|
|
2009
2061
|
this.micQuality = quality;
|
|
2010
2062
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
@@ -2080,7 +2132,8 @@ export class StreamClient {
|
|
|
2080
2132
|
const current = this.cameraProducer();
|
|
2081
2133
|
const track = current?.track;
|
|
2082
2134
|
if (!current || !track || track.readyState !== 'live') return;
|
|
2083
|
-
|
|
2135
|
+
const camera = this.cameraSource(track);
|
|
2136
|
+
try { await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
2084
2137
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
2085
2138
|
await this.reproduceCamera(current, track);
|
|
2086
2139
|
});
|
|
@@ -2130,40 +2183,51 @@ export class StreamClient {
|
|
|
2130
2183
|
// but the camera is still being published: the republish sends this.
|
|
2131
2184
|
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
2132
2185
|
if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
if (replaced && replaced !== track) {
|
|
2140
|
-
track.enabled = replaced.enabled;
|
|
2141
|
-
replaced.enabled = true;
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
this.adoptVideoTrack(track);
|
|
2145
|
-
if (!current || current.track === track) return;
|
|
2146
|
-
const previous = current.track;
|
|
2147
|
-
const paused = current.paused;
|
|
2186
|
+
// The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
|
|
2187
|
+
// processor too, when there is one.
|
|
2188
|
+
const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2189
|
+
const previousSource = shown ? this.cameraSource(shown) : null;
|
|
2190
|
+
const before = this.camOut;
|
|
2191
|
+
const sent = await this.processCamera(track);
|
|
2148
2192
|
try {
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2193
|
+
if (!current) {
|
|
2194
|
+
// The republish reads the camera's state off the track it sends, and
|
|
2195
|
+
// the track being replaced is switched off exactly when the camera is
|
|
2196
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
2197
|
+
// and the old track back switched on, as below.
|
|
2198
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
2199
|
+
if (replaced && replaced !== sent) {
|
|
2200
|
+
sent.enabled = replaced.enabled;
|
|
2201
|
+
replaced.enabled = true;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
this.adoptVideoTrack(sent);
|
|
2205
|
+
if (!current || current.track === sent) return;
|
|
2206
|
+
const previous = current.track;
|
|
2207
|
+
const paused = current.paused;
|
|
2208
|
+
try {
|
|
2209
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
2210
|
+
const frameRate = this.frameRateFor(track);
|
|
2211
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
2212
|
+
const previousSize = previousSource?.getSettings();
|
|
2213
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
2214
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
2215
|
+
await current.replaceTrack({ track: sent });
|
|
2216
|
+
this.watchTrackEnded(current);
|
|
2217
|
+
await this.applyLayerBitrates(current, next);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
await this.reproduceCamera(current, sent);
|
|
2221
|
+
} finally {
|
|
2222
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
2223
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
2224
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
2225
|
+
// off, that canvas stays black after the camera is resumed.
|
|
2226
|
+
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
2159
2227
|
}
|
|
2160
|
-
await this.reproduceCamera(current, track);
|
|
2161
2228
|
} finally {
|
|
2162
|
-
//
|
|
2163
|
-
|
|
2164
|
-
// app's again, often the camera its canvas draws from: handed back
|
|
2165
|
-
// off, that canvas stays black after the camera is resumed.
|
|
2166
|
-
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
2229
|
+
// The processor's previous picture is no longer sent or shown.
|
|
2230
|
+
if (before && before !== this.camOut) before.stop();
|
|
2167
2231
|
}
|
|
2168
2232
|
});
|
|
2169
2233
|
}
|
|
@@ -2342,18 +2406,28 @@ export class StreamClient {
|
|
|
2342
2406
|
paused = !track.enabled,
|
|
2343
2407
|
): Promise<Producer> {
|
|
2344
2408
|
if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
|
|
2409
|
+
// With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
|
|
2410
|
+
// picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
|
|
2411
|
+
const source = this.cameraSource(track);
|
|
2412
|
+
const before = this.camOut;
|
|
2413
|
+
const beforeSource = this.camRaw;
|
|
2414
|
+
const sent = await this.processCamera(source);
|
|
2415
|
+
if (generation !== this.publishGeneration) {
|
|
2416
|
+
if (sent !== source && sent !== before) sent.stop();
|
|
2417
|
+
throw unpublishedWhile('camera');
|
|
2418
|
+
}
|
|
2345
2419
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
2346
2420
|
// off. Setting the track to the camera's state first makes the producer
|
|
2347
2421
|
// start in it, and a camera that is off never sends a frame of the new
|
|
2348
2422
|
// track; the server is told below.
|
|
2349
|
-
|
|
2423
|
+
sent.enabled = !paused;
|
|
2350
2424
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
2351
2425
|
// The layers follow the picture's real size, the project's quality and
|
|
2352
2426
|
// the device, so the browser sends every layer asked for (see
|
|
2353
2427
|
// videoEncodings).
|
|
2354
|
-
const { width = 0, height = 0 } =
|
|
2428
|
+
const { width = 0, height = 0 } = source.getSettings();
|
|
2355
2429
|
// The frame rate first: the size the project allows depends on it.
|
|
2356
|
-
this.cameraFrameRate = this.frameRateFor(
|
|
2430
|
+
this.cameraFrameRate = this.frameRateFor(source);
|
|
2357
2431
|
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
2358
2432
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
2359
2433
|
let codecs: RtpCodecCapability[] | undefined;
|
|
@@ -2365,7 +2439,7 @@ export class StreamClient {
|
|
|
2365
2439
|
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
2366
2440
|
const produce = (codec: RtpCodecCapability | undefined) =>
|
|
2367
2441
|
transport.produce({
|
|
2368
|
-
track,
|
|
2442
|
+
track: sent,
|
|
2369
2443
|
stopTracks: false,
|
|
2370
2444
|
encodings,
|
|
2371
2445
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
@@ -2385,6 +2459,10 @@ export class StreamClient {
|
|
|
2385
2459
|
// unpublish() may have run while this was being made (a publish, a
|
|
2386
2460
|
// quality change or a track swap under way).
|
|
2387
2461
|
await this.keepProducer(producer, generation, 'camera');
|
|
2462
|
+
if (sent !== track) this.adoptVideoTrack(sent);
|
|
2463
|
+
if (before && before !== sent) before.stop();
|
|
2464
|
+
// A second publish brought another camera: the one the processor drew from before is no longer published.
|
|
2465
|
+
if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw) beforeSource.stop();
|
|
2388
2466
|
this.watchTrackEnded(producer);
|
|
2389
2467
|
this.syncPublishProtection();
|
|
2390
2468
|
this.cameraEncodings = encodings;
|
|
@@ -2732,7 +2810,8 @@ export class StreamClient {
|
|
|
2732
2810
|
const producer = this.cameraProducer();
|
|
2733
2811
|
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed) return;
|
|
2734
2812
|
this.frameRateFallback = true;
|
|
2735
|
-
|
|
2813
|
+
const camera = this.cameraSource(producer.track);
|
|
2814
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
2736
2815
|
await this.reproduceCamera(producer, producer.track);
|
|
2737
2816
|
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
2738
2817
|
}).catch((error) => this.emit('error', error as Error));
|
|
@@ -2970,8 +3049,10 @@ export class StreamClient {
|
|
|
2970
3049
|
if (adaptive) layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2971
3050
|
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2972
3051
|
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
3052
|
+
// Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
|
|
3053
|
+
// frame is already the right size without a round trip in front of it (P3-6).
|
|
2973
3054
|
if (layers) {
|
|
2974
|
-
|
|
3055
|
+
void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2975
3056
|
}
|
|
2976
3057
|
current(consumer);
|
|
2977
3058
|
|
|
@@ -3035,6 +3116,131 @@ export class StreamClient {
|
|
|
3035
3116
|
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
3036
3117
|
}
|
|
3037
3118
|
|
|
3119
|
+
// ------------------------------------------------------- audio processor
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
|
|
3123
|
+
* suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
|
|
3124
|
+
* published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
|
|
3125
|
+
* new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
|
|
3126
|
+
* keeps the microphone off: it is sent as it is, and `error` says why.
|
|
3127
|
+
*/
|
|
3128
|
+
async setAudioProcessor(processor: AudioTrackProcessor | null): Promise<void> {
|
|
3129
|
+
await this.micTask(async () => {
|
|
3130
|
+
const previous = this.audioProcessor;
|
|
3131
|
+
this.audioProcessor = processor;
|
|
3132
|
+
const producer = this.micProducer();
|
|
3133
|
+
const device = producer ? this.deviceTrackOf(producer) : null;
|
|
3134
|
+
if (producer && device && !producer.closed) {
|
|
3135
|
+
const sent = await this.processMic(device);
|
|
3136
|
+
await producer.replaceTrack({ track: sent });
|
|
3137
|
+
this.micRaw = sent !== device ? device : null;
|
|
3138
|
+
}
|
|
3139
|
+
if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
/** The processor in use now, or null. */
|
|
3144
|
+
get audioProcessorInUse(): AudioTrackProcessor | null {
|
|
3145
|
+
return this.audioProcessor;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
/** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
|
|
3149
|
+
private async processMic(track: MediaStreamTrack): Promise<MediaStreamTrack> {
|
|
3150
|
+
const processor = this.audioProcessor;
|
|
3151
|
+
if (!processor) return track;
|
|
3152
|
+
try {
|
|
3153
|
+
return await processor.process(track);
|
|
3154
|
+
} catch (err) {
|
|
3155
|
+
this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${(err as Error)?.message ?? err}`));
|
|
3156
|
+
return track;
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
/** The device's own track behind a producer: for the microphone, the one before any processor. */
|
|
3161
|
+
private deviceTrackOf(producer: Producer): MediaStreamTrack | null {
|
|
3162
|
+
const source = producer.appData.source;
|
|
3163
|
+
if (source === 'mic') return this.micRaw ?? producer.track;
|
|
3164
|
+
return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
/**
|
|
3168
|
+
* Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
|
|
3169
|
+
* or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
|
|
3170
|
+
* picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
|
|
3171
|
+
* same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
|
|
3172
|
+
* new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
|
|
3173
|
+
* the camera off: it is sent as it is, and `error` says why.
|
|
3174
|
+
*/
|
|
3175
|
+
async setVideoProcessor(processor: VideoTrackProcessor | null): Promise<void> {
|
|
3176
|
+
await this.cameraTask(async () => {
|
|
3177
|
+
const previous = this.videoProcessor;
|
|
3178
|
+
this.videoProcessor = processor;
|
|
3179
|
+
const producer = this.cameraProducer();
|
|
3180
|
+
const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3181
|
+
if (shown && shown.readyState === 'live') {
|
|
3182
|
+
const source = this.cameraSource(shown);
|
|
3183
|
+
const before = this.camOut;
|
|
3184
|
+
const paused = producer ? producer.paused : !shown.enabled;
|
|
3185
|
+
// Made afresh, by the new processor.
|
|
3186
|
+
this.camRaw = null;
|
|
3187
|
+
this.camOut = null;
|
|
3188
|
+
const sent = await this.processCamera(source);
|
|
3189
|
+
sent.enabled = !paused;
|
|
3190
|
+
if (producer && !producer.closed && producer.track !== sent) await producer.replaceTrack({ track: sent });
|
|
3191
|
+
this.adoptVideoTrack(sent);
|
|
3192
|
+
if (before && before !== sent) before.stop();
|
|
3193
|
+
}
|
|
3194
|
+
if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/** The video processor in use now, or null. */
|
|
3199
|
+
get videoProcessorInUse(): VideoTrackProcessor | null {
|
|
3200
|
+
return this.videoProcessor;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
/** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
|
|
3204
|
+
private cameraSource(track: MediaStreamTrack): MediaStreamTrack {
|
|
3205
|
+
return track === this.camOut && this.camRaw ? this.camRaw : track;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/**
|
|
3209
|
+
* The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
|
|
3210
|
+
* already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
|
|
3211
|
+
*/
|
|
3212
|
+
private async processCamera(source: MediaStreamTrack): Promise<MediaStreamTrack> {
|
|
3213
|
+
const processor = this.videoProcessor;
|
|
3214
|
+
if (!processor) {
|
|
3215
|
+
this.camRaw = null;
|
|
3216
|
+
this.camOut = null;
|
|
3217
|
+
return source;
|
|
3218
|
+
}
|
|
3219
|
+
if (this.camRaw === source && this.camOut?.readyState === 'live') return this.camOut;
|
|
3220
|
+
try {
|
|
3221
|
+
const out = await processor.process(source);
|
|
3222
|
+
// The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
|
|
3223
|
+
source.enabled = true;
|
|
3224
|
+
this.camRaw = source;
|
|
3225
|
+
this.camOut = out;
|
|
3226
|
+
return out;
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${(err as Error)?.message ?? err}`));
|
|
3229
|
+
this.camRaw = null;
|
|
3230
|
+
this.camOut = null;
|
|
3231
|
+
return source;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
/** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
|
|
3236
|
+
private stopLocalTracks(): void {
|
|
3237
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
3238
|
+
this.camRaw?.stop();
|
|
3239
|
+
this.camOut?.stop();
|
|
3240
|
+
this.camRaw = null;
|
|
3241
|
+
this.camOut = null;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3038
3244
|
// ------------------------------------------------------- diagnostics
|
|
3039
3245
|
|
|
3040
3246
|
/**
|
|
@@ -3239,8 +3445,10 @@ export class StreamClient {
|
|
|
3239
3445
|
try {
|
|
3240
3446
|
const mic = this.micProducer();
|
|
3241
3447
|
const camera = this.cameraProducer();
|
|
3242
|
-
|
|
3243
|
-
const
|
|
3448
|
+
// A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
|
|
3449
|
+
const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values() as Iterable<Record<string, unknown>>).level : null;
|
|
3450
|
+
// A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
|
|
3451
|
+
const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values() as Iterable<Record<string, unknown>>).fps : null;
|
|
3244
3452
|
if (!this.healthTimer) return;
|
|
3245
3453
|
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
3246
3454
|
// An interrupted device that is no longer published never says it came back: that is over too.
|
|
@@ -3278,7 +3486,9 @@ export class StreamClient {
|
|
|
3278
3486
|
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3279
3487
|
if (!md?.getUserMedia) throw new Error('switchDevice: this browser cannot capture media');
|
|
3280
3488
|
if (kind === 'videoinput') {
|
|
3281
|
-
const
|
|
3489
|
+
const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3490
|
+
// With a video processor, the camera it draws from.
|
|
3491
|
+
const current = shown ? this.cameraSource(shown) : null;
|
|
3282
3492
|
if (!current) throw new Error('switchDevice: no camera is being published');
|
|
3283
3493
|
if (!deviceIdOf(current)) {
|
|
3284
3494
|
throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
|
|
@@ -3313,8 +3523,10 @@ export class StreamClient {
|
|
|
3313
3523
|
|
|
3314
3524
|
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
3315
3525
|
private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3316
|
-
const previous = producer.track;
|
|
3317
|
-
await
|
|
3526
|
+
const previous = this.micRaw ?? producer.track;
|
|
3527
|
+
const sent = await this.processMic(track);
|
|
3528
|
+
await producer.replaceTrack({ track: sent });
|
|
3529
|
+
this.micRaw = sent !== track ? track : null;
|
|
3318
3530
|
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
3319
3531
|
this.localStream = new MediaStream([track, ...video]);
|
|
3320
3532
|
this.emit('localStreamChanged', this.localStream);
|
|
@@ -3324,14 +3536,15 @@ export class StreamClient {
|
|
|
3324
3536
|
|
|
3325
3537
|
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
3326
3538
|
private watchTrackEnded(producer: Producer): void {
|
|
3327
|
-
|
|
3539
|
+
// The device's track, not the processed one a processor sends: that is the one a device ends.
|
|
3540
|
+
const track = this.deviceTrackOf(producer);
|
|
3328
3541
|
if (!track || typeof track.addEventListener !== 'function') return;
|
|
3329
3542
|
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
3330
3543
|
// The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
|
|
3331
3544
|
const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
|
|
3332
3545
|
if (!which) return;
|
|
3333
3546
|
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
3334
|
-
const current = () => !this.closedByUs && !producer.closed && producer
|
|
3547
|
+
const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
|
|
3335
3548
|
track.addEventListener('mute', () => {
|
|
3336
3549
|
if (!current() || this.interruptions.has(which)) return;
|
|
3337
3550
|
this.interruptions.set(which, setTimeout(() => {
|
|
@@ -3353,7 +3566,7 @@ export class StreamClient {
|
|
|
3353
3566
|
* swapped for another camera on its own (Chime stops it the same way).
|
|
3354
3567
|
*/
|
|
3355
3568
|
private async onTrackEnded(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3356
|
-
const live = () => !this.closedByUs && !producer.closed && producer
|
|
3569
|
+
const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
|
|
3357
3570
|
if (!live()) return;
|
|
3358
3571
|
const deviceId = deviceIdOf(track);
|
|
3359
3572
|
if (producer.appData.source === 'mic') {
|
|
@@ -3729,7 +3942,7 @@ export class StreamClient {
|
|
|
3729
3942
|
// nothing is being held back once nothing is being sent.
|
|
3730
3943
|
const cleared = this.limitWatch.reset();
|
|
3731
3944
|
if (cleared) this.emit('videoLimited', cleared);
|
|
3732
|
-
this.
|
|
3945
|
+
this.stopLocalTracks();
|
|
3733
3946
|
this.localStream = null;
|
|
3734
3947
|
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
3735
3948
|
// and microphone again and publish them, after the app unpublished them.
|