@voltkit-rtc/client 0.9.1 → 0.9.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltkit-rtc/client",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.",
5
5
  "keywords": [
6
6
  "voltkit",
package/src/adaptive.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The app attaches the element that shows a video (`attachVideo`). From that
6
6
  * element's size on screen (CSS pixels × devicePixelRatio, at most 2) the
7
- * smallest simulcast layer that fills it sharply is chosen; while no attached
7
+ * smallest simulcast layer that fills it without being stretched is chosen; while no attached
8
8
  * element is visible (scrolled away, `display: none`, the tab in the
9
9
  * background) the video is paused at the media server, so it costs no
10
10
  * bandwidth and no decoding. This is what LiveKit calls adaptiveStream and
@@ -27,12 +27,16 @@ export interface ViewBox {
27
27
 
28
28
  /** Never ask for more than twice the CSS size: a 3× phone screen gains nothing visible from it. */
29
29
  export const MAX_PIXEL_RATIO = 2;
30
- /** A layer shown at most this much bigger than it is still looks sharp: avoids a whole layer for a few pixels. */
31
- export const MAX_UPSCALE = 1.15;
30
+ /**
31
+ * A layer is never stretched on screen: a box bigger than a layer gets the next one up. Up to 0.9.1 a layer could be
32
+ * shown 15% bigger than it is, which looks soft on a face (owner, 2026-10-20: "quality should look good"). The 2% is
33
+ * for rounding only, so a box a pixel or two bigger than a layer keeps it.
34
+ */
35
+ export const MAX_UPSCALE = 1.02;
32
36
 
33
37
  /**
34
- * The smallest layer that fills `box` sharply, or null when the layers are
35
- * not known (an older publisher): then the server chooses, as before.
38
+ * The smallest layer that fills `box` without being stretched, or null when
39
+ * the layers are not known (an older publisher): then the server chooses, as before.
36
40
  *
37
41
  * With `cover` the picture is scaled until it fills the box (a part is
38
42
  * cropped), with `contain` until it fits. Either way the scale is measured
@@ -46,7 +50,7 @@ export function layerForBox(layers: readonly VideoLayer[], box: ViewBox, pixelRa
46
50
  const h = box.height * ratio;
47
51
  for (const layer of usable) {
48
52
  const scale = box.fit === 'cover' ? Math.max(w / layer.width, h / layer.height) : Math.min(w / layer.width, h / layer.height);
49
- // The layer is at most slightly upscaled on screen.
53
+ // The layer is not stretched on screen (see MAX_UPSCALE).
50
54
  if (scale <= MAX_UPSCALE) return layer.spatialLayer;
51
55
  }
52
56
  return usable[usable.length - 1]!.spatialLayer;
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  /** This package's version; kept equal to package.json by a test. */
10
- export const SDK_VERSION = '0.9.1';
10
+ export const SDK_VERSION = '0.9.3';
11
11
 
12
12
  /** How many recent events the log keeps. */
13
13
  export const DIAGNOSTICS_LOG_SIZE = 200;
package/src/index.ts CHANGED
@@ -564,6 +564,12 @@ interface PreferredLayers {
564
564
 
565
565
  const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
566
566
 
567
+ /** A viewer's choice for one video: a quality (setReceiveQuality, which also sets the layers) or a ceiling (setPreferredLayers). */
568
+ interface ReceiveChoice {
569
+ quality: ReceiveQuality | undefined;
570
+ layers: PreferredLayers | undefined;
571
+ }
572
+
567
573
  /**
568
574
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
569
575
  * otherwise starts low and takes many seconds to climb to the top layer.
@@ -732,6 +738,14 @@ export class StreamClient {
732
738
  private readonly screenProducerIds = new Set<string>();
733
739
  private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
734
740
  private readonly receiveQualities = new Map<string, ReceiveQuality>();
741
+ /**
742
+ * BR-09 (the Flutter SDK's V092-02): per video, the choice the server last accepted, and a revision every change
743
+ * bumps. A choice is stored before its request (what is asked for is read from it), so a request that fails goes
744
+ * back to the last accepted choice -- not merely the one before it, which may itself be waiting or refused -- and
745
+ * only the latest change may go back, so a late failure never undoes a newer choice. Replies come in request order.
746
+ */
747
+ private readonly receiveAccepted = new Map<string, ReceiveChoice>();
748
+ private readonly receiveRevision = new Map<string, number>();
735
749
  private cameraFrameRate: 30 | 60 = 30;
736
750
  private frameRateFallback = false;
737
751
  private frameRateLowSamples = 0;
@@ -1104,10 +1118,9 @@ export class StreamClient {
1104
1118
  // A video that ended while this client was away sends it no
1105
1119
  // producerClosed: forget the layers asked for it.
1106
1120
  const present = new Set(result.producers.map((p) => p.producerId));
1107
- for (const producerId of this.preferredLayers.keys()) {
1121
+ for (const producerId of new Set([...this.preferredLayers.keys(), ...this.receiveAccepted.keys()])) {
1108
1122
  if (!present.has(producerId)) {
1109
- this.preferredLayers.delete(producerId);
1110
- this.receiveQualities.delete(producerId);
1123
+ this.forgetReceiveChoice(producerId);
1111
1124
  this.remoteVideoLayers.delete(producerId);
1112
1125
  }
1113
1126
  }
@@ -1322,6 +1335,8 @@ export class StreamClient {
1322
1335
  this.teardownMedia();
1323
1336
  this.preferredLayers.clear();
1324
1337
  this.receiveQualities.clear();
1338
+ this.receiveAccepted.clear();
1339
+ this.receiveRevision.clear();
1325
1340
  this.remoteVideoLayers.clear();
1326
1341
  this.releaseScreen();
1327
1342
  this.music?.close();
@@ -1603,8 +1618,7 @@ export class StreamClient {
1603
1618
  case 'producerClosed': {
1604
1619
  const { producerId, identity } = data as { producerId: string; identity: string };
1605
1620
  this.subscriptions.delete(producerId);
1606
- this.preferredLayers.delete(producerId);
1607
- this.receiveQualities.delete(producerId);
1621
+ this.forgetReceiveChoice(producerId);
1608
1622
  this.remoteVideoLayers.delete(producerId);
1609
1623
  this.skippedVideos.delete(producerId);
1610
1624
  this.adaptivePaused.delete(producerId);
@@ -2188,7 +2202,10 @@ export class StreamClient {
2188
2202
  const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
2189
2203
  const previousSource = shown ? this.cameraSource(shown) : null;
2190
2204
  const before = this.camOut;
2205
+ const beforeRaw = this.camRaw;
2206
+ const wasPaused = current?.paused ?? false;
2191
2207
  const sent = await this.processCamera(track);
2208
+ let swapped = false;
2192
2209
  try {
2193
2210
  if (!current) {
2194
2211
  // The republish reads the camera's state off the track it sends, and
@@ -2202,7 +2219,10 @@ export class StreamClient {
2202
2219
  }
2203
2220
  }
2204
2221
  this.adoptVideoTrack(sent);
2205
- if (!current || current.track === sent) return;
2222
+ if (!current || current.track === sent) {
2223
+ swapped = true;
2224
+ return;
2225
+ }
2206
2226
  const previous = current.track;
2207
2227
  const paused = current.paused;
2208
2228
  try {
@@ -2213,11 +2233,13 @@ export class StreamClient {
2213
2233
  if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
2214
2234
  previousSize?.width === width && previousSize?.height === height) {
2215
2235
  await current.replaceTrack({ track: sent });
2236
+ swapped = true;
2216
2237
  this.watchTrackEnded(current);
2217
2238
  await this.applyLayerBitrates(current, next);
2218
2239
  return;
2219
2240
  }
2220
2241
  await this.reproduceCamera(current, sent);
2242
+ swapped = true;
2221
2243
  } finally {
2222
2244
  // Pausing switched the old track off (mediasoup-client pauses that
2223
2245
  // way), and nothing switches it back on once it is replaced. It is the
@@ -2225,13 +2247,55 @@ export class StreamClient {
2225
2247
  // off, that canvas stays black after the camera is resumed.
2226
2248
  if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
2227
2249
  }
2250
+ } catch (err) {
2251
+ if (!swapped) {
2252
+ await this.undoCameraSwap({ producer: current ?? null, given: track, made: sent, shown, source: previousSource, raw: beforeRaw, out: before, paused: wasPaused });
2253
+ }
2254
+ throw err;
2228
2255
  } finally {
2229
- // The processor's previous picture is no longer sent or shown.
2230
- if (before && before !== this.camOut) before.stop();
2256
+ // The processor's previous picture is no longer sent or shown -- once the new one is.
2257
+ if (swapped && before && before !== this.camOut) before.stop();
2231
2258
  }
2232
2259
  });
2233
2260
  }
2234
2261
 
2262
+ /**
2263
+ * A camera swap that failed (BR-12; the Flutter SDK's V090-02). What is sent is still the old picture -- or nothing,
2264
+ * when the producer was replaced and the new one failed. The client's stream goes back to the old picture, so the
2265
+ * app's preview and a reconnect never use a track that did not go on air. With a processor, the old camera is put
2266
+ * back through it if it dropped the old picture when given the new track. The picture made for the new track is
2267
+ * released; the new track itself is the caller's.
2268
+ */
2269
+ private async undoCameraSwap(undo: {
2270
+ producer: Producer | null; given: MediaStreamTrack; made: MediaStreamTrack; shown: MediaStreamTrack | null;
2271
+ source: MediaStreamTrack | null; raw: MediaStreamTrack | null; out: MediaStreamTrack | null; paused: boolean;
2272
+ }): Promise<void> {
2273
+ const { producer, given, made, shown, source, raw, out, paused } = undo;
2274
+ if (made !== given) made.stop();
2275
+ const sending = () => producer !== null && !producer.closed && this.cameraProducer() === producer;
2276
+ let picture = shown;
2277
+ if (shown && shown.readyState === 'ended' && source && source !== shown && source.readyState !== 'ended') {
2278
+ // The processor dropped the picture on air: make it again from the old camera, and send that.
2279
+ picture = await this.processCamera(source);
2280
+ if (sending() && producer && producer.track !== picture) {
2281
+ try {
2282
+ await producer.replaceTrack({ track: picture });
2283
+ this.watchTrackEnded(producer);
2284
+ } catch (err) {
2285
+ this.emit('error', new Error(`the camera could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
2286
+ }
2287
+ }
2288
+ } else {
2289
+ // The picture on air is as it was, and so is what stands behind it.
2290
+ this.camRaw = raw;
2291
+ this.camOut = out;
2292
+ }
2293
+ if (!picture || picture.readyState === 'ended') return;
2294
+ // With nothing sending it, a republish reads the camera's state off the track: a paused camera stays paused.
2295
+ if (!sending()) picture.enabled = !paused;
2296
+ this.adoptVideoTrack(picture);
2297
+ }
2298
+
2235
2299
  /**
2236
2300
  * Give a camera producer whose track was swapped in place the bitrates of
2237
2301
  * its new picture. The layout matched, but a bigger picture at the same
@@ -2302,8 +2366,10 @@ export class StreamClient {
2302
2366
  * 1280×720), and a layer above the biggest means the biggest. Leave
2303
2367
  * `temporalLayer` out to keep the full frame rate. It is the most this
2304
2368
  * client is sent: a connection that cannot carry it still gets less. The
2305
- * choice is kept across reconnects and forgotten when the track ends.
2306
- * Rejects for a producer whose video this client is not receiving.
2369
+ * choice is kept across reconnects and forgotten when the track ends; a
2370
+ * call that fails (refused, or the connection lost before the answer)
2371
+ * leaves the previous choice in place. Rejects for a producer whose video
2372
+ * this client is not receiving.
2307
2373
  */
2308
2374
  async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
2309
2375
  if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
@@ -2314,12 +2380,18 @@ export class StreamClient {
2314
2380
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
2315
2381
  }
2316
2382
  const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
2317
- this.receiveQualities.delete(producerId);
2318
- // Kept first: a reconnect that interrupts the request still applies it.
2319
- this.preferredLayers.set(producerId, layers);
2383
+ // Stored before the request, which reads it; a request that fails (refused, or the connection lost before the
2384
+ // answer) leaves the last accepted choice in place (BR-09).
2385
+ const change = this.changeReceive(producerId, { quality: undefined, layers });
2320
2386
  // Shown through attachVideo: the lower of this and the size it is shown at.
2321
2387
  const adaptive = this.adaptive.current(producerId);
2322
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2388
+ try {
2389
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2390
+ } catch (err) {
2391
+ change.failed();
2392
+ throw err;
2393
+ }
2394
+ change.accepted();
2323
2395
  }
2324
2396
 
2325
2397
  private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
@@ -2337,7 +2409,14 @@ export class StreamClient {
2337
2409
  return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
2338
2410
  }
2339
2411
 
2340
- /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
2412
+ /**
2413
+ * The quality this viewer gets of one remote video. '1080p', '720p' and '480p' pick the publisher's biggest layer
2414
+ * at or below that size (a 720p camera cannot give 1080p), and the viewer gets it while the video is shown, however
2415
+ * big it is on screen: someone who picks 1080p expects 1080p, not the smaller copy that fits the tile. A connection
2416
+ * that cannot carry it still gets less until it recovers, and a video shown through attachVideo is still paused
2417
+ * while nobody can see it. 'auto' (the default) goes back to the size it is shown at, or the server's choice.
2418
+ * Kept across reconnects; a call that fails leaves the previous choice in place.
2419
+ */
2341
2420
  async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
2342
2421
  if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
2343
2422
  const consumer = this.consumers.get(producerId);
@@ -2348,10 +2427,48 @@ export class StreamClient {
2348
2427
  try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
2349
2428
  catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
2350
2429
  }
2351
- this.receiveQualities.set(producerId, quality);
2352
- this.preferredLayers.set(producerId, layers);
2430
+ const change = this.changeReceive(producerId, { quality, layers });
2353
2431
  const adaptive = this.adaptive.current(producerId);
2354
- await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2432
+ try {
2433
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
2434
+ } catch (err) {
2435
+ change.failed();
2436
+ throw err;
2437
+ }
2438
+ change.accepted();
2439
+ }
2440
+
2441
+ /** Store `choice` for one video now, before its request; the caller reports how the request ended (BR-09). */
2442
+ private changeReceive(producerId: string, choice: ReceiveChoice): { accepted(): void; failed(): void } {
2443
+ if (!this.receiveAccepted.has(producerId)) {
2444
+ this.receiveAccepted.set(producerId, { quality: this.receiveQualities.get(producerId), layers: this.preferredLayers.get(producerId) });
2445
+ }
2446
+ const revision = (this.receiveRevision.get(producerId) ?? 0) + 1;
2447
+ this.receiveRevision.set(producerId, revision);
2448
+ this.setReceiveChoice(producerId, choice);
2449
+ return {
2450
+ // Forgotten meanwhile (the video ended, the session closed): a late answer changes nothing.
2451
+ accepted: () => { if (this.receiveRevision.has(producerId)) this.receiveAccepted.set(producerId, choice); },
2452
+ failed: () => {
2453
+ const back = this.receiveAccepted.get(producerId);
2454
+ if (this.receiveRevision.get(producerId) === revision && back) this.setReceiveChoice(producerId, back);
2455
+ },
2456
+ };
2457
+ }
2458
+
2459
+ private setReceiveChoice(producerId: string, choice: ReceiveChoice): void {
2460
+ if (choice.quality === undefined) this.receiveQualities.delete(producerId);
2461
+ else this.receiveQualities.set(producerId, choice.quality);
2462
+ if (choice.layers === undefined) this.preferredLayers.delete(producerId);
2463
+ else this.preferredLayers.set(producerId, choice.layers);
2464
+ }
2465
+
2466
+ /** The video ended: its choices go with it. */
2467
+ private forgetReceiveChoice(producerId: string): void {
2468
+ this.preferredLayers.delete(producerId);
2469
+ this.receiveQualities.delete(producerId);
2470
+ this.receiveAccepted.delete(producerId);
2471
+ this.receiveRevision.delete(producerId);
2355
2472
  }
2356
2473
 
2357
2474
  private cameraProducer(): Producer | undefined {
@@ -3089,26 +3206,34 @@ export class StreamClient {
3089
3206
  * Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
3090
3207
  * stream with receiveWhenViewVisible): tell the SDK which element shows a
3091
3208
  * remote video. It then receives the smallest layer that fills that element
3092
- * sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
3209
+ * without stretching it (its size on screen × devicePixelRatio, at most 2) and pauses the
3093
3210
  * video at the server while the element is not visible (scrolled away,
3094
3211
  * `display: none`, or the tab in the background), so a grid of small tiles
3095
3212
  * stops decoding full-size video. Attach the same video to several elements
3096
3213
  * and the largest visible one decides. Returns the function that detaches
3097
- * it; detach when the element goes away. A ceiling set with
3098
- * `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
3099
- * server; one without `pauseConsumer` gets the smallest layer instead of a
3100
- * pause.
3214
+ * it; detach when the element goes away. A quality chosen with
3215
+ * `setReceiveQuality` replaces the size it is shown at (the video is still
3216
+ * paused while hidden), and a ceiling set with `setPreferredLayers` still
3217
+ * applies. Works with any server; one without `pauseConsumer` gets the
3218
+ * smallest layer instead of a pause.
3101
3219
  */
3102
3220
  attachVideo(producerId: string, element: Element): () => void {
3103
3221
  return this.adaptive.attach(producerId, element);
3104
3222
  }
3105
3223
 
3106
- /** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
3224
+ /**
3225
+ * The layers to ask for. A quality chosen with setReceiveQuality is the layer, whatever the size on screen (owner,
3226
+ * 2026-10-20: viewers who picked 1080p got 540p, because it was only a ceiling). A ceiling set with
3227
+ * setPreferredLayers and adaptive stream's size: the lower of the two.
3228
+ */
3107
3229
  private effectiveLayers(producerId: string, want: AdaptiveWant | null): PreferredLayers {
3230
+ // Hidden: paused at the server, or held at the smallest layer where it cannot pause. A chosen quality or a ceiling
3231
+ // applies once the video is shown again, never while nobody can see it (BR-08; the Flutter SDK's V092-01).
3232
+ if (want && !want.visible) return { spatialLayer: 0, temporalLayer: 0 };
3108
3233
  let manual: PreferredLayers | undefined = this.preferredLayers.get(producerId);
3109
3234
  const quality = this.receiveQualities.get(producerId);
3110
3235
  if (quality && quality !== 'auto') {
3111
- try { manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; } catch { manual = undefined; }
3236
+ try { return { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; } catch { manual = undefined; }
3112
3237
  }
3113
3238
  const adaptive = want?.visible ? want.spatialLayer : null;
3114
3239
  if (adaptive === null || adaptive === undefined) return manual ?? { spatialLayer: -1, temporalLayer: -1 };
@@ -3476,7 +3601,9 @@ export class StreamClient {
3476
3601
  * captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
3477
3602
  * published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
3478
3603
  * replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
3479
- * reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
3604
+ * reconnect captures the chosen device again. A camera or microphone that is switched off stays off. A swap that
3605
+ * fails releases the new device and keeps the old one on air, put back through a noise suppressor or video effects
3606
+ * if one is set.
3480
3607
  *
3481
3608
  * For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
3482
3609
  * processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
@@ -3516,16 +3643,35 @@ export class StreamClient {
3516
3643
  track.stop();
3517
3644
  throw new Error('switchDevice: the microphone was unpublished meanwhile');
3518
3645
  }
3519
- await this.adoptAudioTrack(producer, track);
3646
+ try {
3647
+ // Releases the new microphone itself if the swap fails.
3648
+ await this.adoptAudioTrack(producer, track);
3649
+ } catch (err) {
3650
+ if (producer.closed || this.micProducer() !== producer) throw new Error('switchDevice: the microphone was unpublished meanwhile');
3651
+ throw err;
3652
+ }
3520
3653
  this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
3521
3654
  });
3522
3655
  }
3523
3656
 
3524
- /** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
3657
+ /**
3658
+ * Put `track` (one the SDK captured) on the microphone producer and in the client's own stream; the replaced track is
3659
+ * stopped. A swap that fails releases `track` and the sound made from it, and with a processor puts the microphone
3660
+ * still on air back through it: given the new track, the processor dropped that sound (BR-10, BR-11).
3661
+ */
3525
3662
  private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
3526
3663
  const previous = this.micRaw ?? producer.track;
3527
- const sent = await this.processMic(track);
3528
- await producer.replaceTrack({ track: sent });
3664
+ const previousRaw = this.micRaw;
3665
+ let sent = track;
3666
+ try {
3667
+ sent = await this.processMic(track);
3668
+ await producer.replaceTrack({ track: sent });
3669
+ } catch (err) {
3670
+ if (sent !== track) sent.stop();
3671
+ track.stop();
3672
+ await this.restoreMic(producer, previousRaw);
3673
+ throw err;
3674
+ }
3529
3675
  this.micRaw = sent !== track ? track : null;
3530
3676
  const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
3531
3677
  this.localStream = new MediaStream([track, ...video]);
@@ -3534,6 +3680,23 @@ export class StreamClient {
3534
3680
  this.watchTrackEnded(producer);
3535
3681
  }
3536
3682
 
3683
+ /** After a failed microphone swap with a processor: send the microphone on air through it again (it dropped that sound). */
3684
+ private async restoreMic(producer: Producer, raw: MediaStreamTrack | null): Promise<void> {
3685
+ const current = () => !producer.closed && this.micProducer() === producer;
3686
+ if (!this.audioProcessor || !raw || raw.readyState === 'ended' || !current()) return;
3687
+ try {
3688
+ const again = await this.processMic(raw);
3689
+ if (!current()) {
3690
+ if (again !== raw) again.stop();
3691
+ return;
3692
+ }
3693
+ await producer.replaceTrack({ track: again });
3694
+ this.micRaw = again !== raw ? raw : null;
3695
+ } catch (err) {
3696
+ this.emit('error', new Error(`the microphone could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
3697
+ }
3698
+ }
3699
+
3537
3700
  /** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
3538
3701
  private watchTrackEnded(producer: Producer): void {
3539
3702
  // The device's track, not the processed one a processor sends: that is the one a device ends.