From 035fc3f741661d569351189b49f5761e29caa8b1 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 15 Apr 2026 20:26:07 -0500 Subject: [PATCH] feat(telephone): improve path discovery and initiation process in TelephoneManager; introduce polling intervals and cancellation checks for improved reliability --- meshchatx/src/backend/telephone_manager.py | 88 +++++++++--- .../src/frontend/components/call/CallPage.vue | 135 ++++++++++++++---- .../src/frontend/js/MicrophoneRecorder.js | 70 ++++++--- tests/frontend/CallPage.test.js | 102 +++++++++++++ tests/frontend/MicrophoneRecorder.test.js | 115 +++++++++++++++ 5 files changed, 446 insertions(+), 64 deletions(-) create mode 100644 tests/frontend/MicrophoneRecorder.test.js diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 79c3e37..43b0192 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -81,6 +81,9 @@ class TelephoneManager: self.initiation_status = None self.initiation_target_hash = None self.on_initiation_status_callback = None + self._path_poll_interval_s = 0.05 + self._path_retry_interval_s = 1.5 + self._status_poll_interval_s = 0.1 @property def is_recording(self): @@ -212,6 +215,30 @@ class TelephoneManager: RNS.LOG_ERROR, ) + def _is_initiation_cancelled(self): + return not bool(self.initiation_status) + + async def _await_path(self, destination_hash: bytes, timeout_seconds: float): + timeout_after = time.monotonic() + max(0.0, timeout_seconds) + next_request_at = 0.0 + + while time.monotonic() < timeout_after: + if self._is_initiation_cancelled(): + return False + + if RNS.Transport.has_path(destination_hash): + return True + + now = time.monotonic() + if now >= next_request_at: + with contextlib.suppress(Exception): + RNS.Transport.request_path(destination_hash) + next_request_at = now + self._path_retry_interval_s + + await asyncio.sleep(self._path_poll_interval_s) + + return RNS.Transport.has_path(destination_hash) + async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15): if self.telephone is None: msg = "Telephone is not initialized" @@ -282,17 +309,24 @@ class TelephoneManager: if destination_identity is None: self._update_initiation_status("Discovering path/identity...") - RNS.Transport.request_path(destination_hash) + timeout_after = time.monotonic() + timeout_seconds + next_request_at = 0.0 - # Wait for identity to appear - start_wait = time.time() - while time.time() - start_wait < timeout_seconds: - if not self.initiation_status: # Externally cancelled (hangup) + # Wait for identity to appear while also nudging path discovery. + while time.monotonic() < timeout_after: + if self._is_initiation_cancelled(): return None - await asyncio.sleep(0.5) + + now = time.monotonic() + if now >= next_request_at: + with contextlib.suppress(Exception): + RNS.Transport.request_path(destination_hash) + next_request_at = now + self._path_retry_interval_s + destination_identity = resolve_identity(destination_hash_hex) if destination_identity: break + await asyncio.sleep(self._path_poll_interval_s) if destination_identity is None: self._update_initiation_status(None, None) @@ -301,16 +335,15 @@ class TelephoneManager: if not RNS.Transport.has_path(destination_hash): self._update_initiation_status("Requesting path...") - RNS.Transport.request_path(destination_hash) - - # Wait up to 10s for path discovery - path_wait_start = time.time() - while time.time() - path_wait_start < min(timeout_seconds, 10): - if not self.initiation_status: # Externally cancelled - return None - if RNS.Transport.has_path(destination_hash): - break - await asyncio.sleep(0.5) + has_path = await self._await_path( + destination_hash, + timeout_seconds=min(timeout_seconds, 10), + ) + if self._is_initiation_cancelled(): + return None + if not has_path: + msg = "Path not found to destination" + raise RuntimeError(msg) self._update_initiation_status("Establishing link...", destination_hash_hex) self.call_start_time = time.time() @@ -323,10 +356,12 @@ class TelephoneManager: ) start_wait = time.time() + cancel_requested = False # LXST telephone.call usually returns on establishment or timeout. # We wait for it, but if status becomes established or ended, we can stop waiting. while not call_task.done(): - if not self.initiation_status: # Externally cancelled + if self._is_initiation_cancelled(): + cancel_requested = True break # Update UI status based on current call state @@ -350,7 +385,12 @@ class TelephoneManager: time.time() - start_wait > 1.0 ): # Available (ended/timeout) break - await asyncio.sleep(0.5) + await asyncio.sleep(self._status_poll_interval_s) + + if cancel_requested: + with contextlib.suppress(Exception): + self.telephone.hangup() + return None # If the task finished but we're still ringing or connecting, # wait a bit more for establishment or definitive failure @@ -387,7 +427,7 @@ class TelephoneManager: 3, ]: # Established, Busy, Rejected, Ended break - await asyncio.sleep(0.5) + await asyncio.sleep(self._status_poll_interval_s) return self.telephone.active_call @@ -396,15 +436,19 @@ class TelephoneManager: await asyncio.sleep(3) raise finally: + if self._is_initiation_cancelled(): + self._update_initiation_status(None, None) + return + # Wait for either establishment, failure, or a timeout # to ensure the UI has something to show (either active_call or initiation_status) - for _ in range(20): # Max 10 seconds of defensive waiting + for _ in range(40): # Max 4 seconds of defensive waiting if self.telephone and ( self.telephone.active_call or self.telephone.call_status in [0, 1, 3, 6] ): break - await asyncio.sleep(0.5) + await asyncio.sleep(self._status_poll_interval_s) # If call was successful, keep status for a moment to prevent UI flicker # while the frontend picks up the new active_call state @@ -412,7 +456,7 @@ class TelephoneManager: (self.telephone.active_call and self.telephone.call_status == 6) or self.telephone.call_status in [2, 4, 5] ): - await asyncio.sleep(2.0) + await asyncio.sleep(1.0) self._update_initiation_status(None, None) def mute_transmit(self): diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 6b72b8c..8da6af4 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -2240,6 +2240,7 @@ export default { audioWs: null, audioCtx: null, audioStream: null, + audioSourceNode: null, audioProcessor: null, audioWorkletNode: null, audioSilentGain: null, @@ -2408,6 +2409,49 @@ export default { formatDuration(seconds) { return Utils.formatMinutesSeconds(seconds); }, + getMediaDevicesApi() { + const mediaDevices = navigator?.mediaDevices; + if ( + !mediaDevices || + typeof mediaDevices.getUserMedia !== "function" || + typeof mediaDevices.enumerateDevices !== "function" + ) { + return null; + } + return mediaDevices; + }, + getAudioContextConstructor() { + return window.AudioContext || window.webkitAudioContext || null; + }, + logWebAudioFailure(stage, error) { + const appImage = Boolean( + window.electron && + typeof navigator?.userAgent === "string" && + navigator.userAgent.includes("AppImage"), + ); + console.error( + `[CallPage:web-audio] ${stage}`, + { + isElectron: Boolean(window.electron), + isAppImage: appImage, + userAgent: navigator?.userAgent || "unknown", + }, + error, + ); + }, + async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") { + this.logWebAudioFailure(stage, error); + ToastUtils.error(this.$t(errorKey)); + if (this.config) { + this.config.telephone_web_audio_enabled = false; + } + try { + await this.updateConfig({ telephone_web_audio_enabled: false }); + } catch (updateError) { + this.logWebAudioFailure("disable-config-update", updateError); + } + this.stopWebAudio(); + }, async ensureWebAudio(webAudioStatus) { if (!this.config?.telephone_web_audio_enabled) { this.stopWebAudio(); @@ -2449,12 +2493,23 @@ export default { return; } try { + const mediaDevices = this.getMediaDevicesApi(); + if (!mediaDevices) { + await this.disableWebAudioBridgeWithError( + "call.web_audio_not_available", + new Error("navigator.mediaDevices is unavailable"), + "start-preflight-media-devices", + ); + return; + } await this.refreshAudioDevices(); const hasInputDevices = (this.audioInputDevices || []).length > 0; if (!hasInputDevices) { - ToastUtils.error(this.$t("call.no_audio_input_found")); - this.config.telephone_web_audio_enabled = false; - await this.updateConfig({ telephone_web_audio_enabled: false }); + await this.disableWebAudioBridgeWithError( + "call.no_audio_input_found", + new Error("No audio input devices detected"), + "start-no-input-devices", + ); return; } @@ -2463,14 +2518,19 @@ export default { const constraints = hasSelectedDevice ? { audio: { deviceId: { exact: this.selectedAudioInputId } } } : { audio: true }; - const stream = await navigator.mediaDevices.getUserMedia(constraints); + const stream = await mediaDevices.getUserMedia(constraints); this.audioStream = stream; if (!this.audioCtx) { - this.audioCtx = new AudioContext({ sampleRate: 48000 }); + const AudioContextCtor = this.getAudioContextConstructor(); + if (!AudioContextCtor) { + throw new Error("AudioContext is unavailable"); + } + this.audioCtx = new AudioContextCtor({ sampleRate: 48000 }); } const source = this.audioCtx.createMediaStreamSource(stream); + this.audioSourceNode = source; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = `${wsProtocol}//${window.location.host}/ws/telephone/audio`; @@ -2510,22 +2570,22 @@ export default { this.audioWs = ws; this.refreshAudioDevices(); } catch (err) { - console.error("Web audio failed", err); const errorKey = err?.name === "NotFoundError" || err?.name === "OverconstrainedError" ? "call.no_audio_input_found" : err?.name === "NotAllowedError" ? "call.microphone_permission_denied" : "call.web_audio_not_available"; - ToastUtils.error(this.$t(errorKey)); - this.config.telephone_web_audio_enabled = false; - await this.updateConfig({ telephone_web_audio_enabled: false }); - this.stopWebAudio(); + await this.disableWebAudioBridgeWithError(errorKey, err, "start-catch"); } }, async requestAudioPermission() { try { - const devices = await navigator.mediaDevices.enumerateDevices(); + const mediaDevices = this.getMediaDevicesApi(); + if (!mediaDevices) { + throw new Error("navigator.mediaDevices is unavailable"); + } + const devices = await mediaDevices.enumerateDevices(); const hasAudioInput = devices.some((d) => d.kind === "audioinput"); if (devices.length > 0 && !hasAudioInput) { ToastUtils.error(this.$t("call.no_audio_input_found")); @@ -2535,12 +2595,12 @@ export default { const constraints = this.selectedAudioInputId ? { audio: { deviceId: { exact: this.selectedAudioInputId } } } : { audio: true }; - const stream = await navigator.mediaDevices.getUserMedia(constraints); + const stream = await mediaDevices.getUserMedia(constraints); stream.getTracks().forEach((t) => t.stop()); await this.refreshAudioDevices(); return true; } catch (e) { - console.error("Permission or device request failed", e); + this.logWebAudioFailure("request-permission", e); const errorKey = e?.name === "NotFoundError" || e?.name === "OverconstrainedError" ? "call.no_audio_input_found" @@ -2553,7 +2613,13 @@ export default { }, async refreshAudioDevices() { try { - const devices = await navigator.mediaDevices.enumerateDevices(); + const mediaDevices = this.getMediaDevicesApi(); + if (!mediaDevices) { + this.audioInputDevices = []; + this.audioOutputDevices = []; + return; + } + const devices = await mediaDevices.enumerateDevices(); this.audioInputDevices = devices.filter((d) => d.kind === "audioinput"); this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput"); if (!this.selectedAudioInputId && this.audioInputDevices.length) { @@ -2563,11 +2629,13 @@ export default { this.selectedAudioOutputId = this.audioOutputDevices[0].deviceId; } } catch (e) { - console.error("Failed to enumerate audio devices", e); + this.logWebAudioFailure("refresh-devices", e); + this.audioInputDevices = []; + this.audioOutputDevices = []; } }, playRemotePcm(arrayBuffer) { - if (!this.audioCtx) { + if (!this.audioCtx || !arrayBuffer) { return; } const pcm = new Int16Array(arrayBuffer); @@ -2598,6 +2666,27 @@ export default { } }, stopWebAudio() { + const ws = this.audioWs; + this.audioWs = null; + if (ws) { + try { + ws.onopen = null; + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + ws.close(); + } catch { + // ignore + } + } + if (this.audioSourceNode) { + try { + this.audioSourceNode.disconnect(); + } catch { + // ignore + } + this.audioSourceNode = null; + } if (this.audioProcessor) { try { this.audioProcessor.disconnect(); @@ -2610,14 +2699,6 @@ export default { this.audioStream.getTracks().forEach((t) => t.stop()); this.audioStream = null; } - if (this.audioWs) { - try { - this.audioWs.close(); - } catch { - // ignore - } - this.audioWs = null; - } if (this.remoteAudioEl) { this.remoteAudioEl.srcObject = null; this.remoteAudioEl = null; @@ -2638,6 +2719,12 @@ export default { } this.audioSilentGain = null; } + if (this.audioCtx && this.audioCtx.state !== "closed") { + this.audioCtx.close().catch(() => { + // ignore + }); + } + this.audioCtx = null; }, async getConfig() { try { diff --git a/meshchatx/src/frontend/js/MicrophoneRecorder.js b/meshchatx/src/frontend/js/MicrophoneRecorder.js index 3c2d94f..aa0f470 100644 --- a/meshchatx/src/frontend/js/MicrophoneRecorder.js +++ b/meshchatx/src/frontend/js/MicrophoneRecorder.js @@ -8,8 +8,30 @@ class MicrophoneRecorder { this.mediaRecorder = null; } + cleanupMediaStream() { + if (!this.microphoneMediaStream) { + return; + } + this.microphoneMediaStream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore track stop failures + } + }); + this.microphoneMediaStream = null; + } + async start() { try { + this.audioChunks = []; + if (!navigator?.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { + return false; + } + if (typeof MediaRecorder !== "function") { + return false; + } + // request access to the microphone this.microphoneMediaStream = await navigator.mediaDevices.getUserMedia({ audio: true, @@ -20,7 +42,9 @@ class MicrophoneRecorder { // handle received audio from media recorder this.mediaRecorder.ondataavailable = (event) => { - this.audioChunks.push(event.data); + if (event?.data) { + this.audioChunks.push(event.data); + } }; // start recording @@ -29,32 +53,42 @@ class MicrophoneRecorder { // successfully started recording return true; } catch { + this.cleanupMediaStream(); + this.mediaRecorder = null; return false; } } async stop() { return new Promise((resolve, reject) => { + if (!this.mediaRecorder) { + reject(new Error("Cannot stop recording before start()")); + return; + } + + const recorder = this.mediaRecorder; + + // handle media recording stopped + recorder.onstop = () => { + const blob = new Blob(this.audioChunks, { + type: recorder.mimeType || "audio/webm;codecs=opus", + }); + this.mediaRecorder = null; + this.cleanupMediaStream(); + resolve(blob); + }; + recorder.onerror = (event) => { + this.mediaRecorder = null; + this.cleanupMediaStream(); + reject(event?.error || new Error("MediaRecorder error while stopping")); + }; + try { - // handle media recording stopped - this.mediaRecorder.onstop = () => { - // stop using microphone - if (this.microphoneMediaStream) { - this.microphoneMediaStream.getTracks().forEach((track) => track.stop()); - } - - // create blob from audio chunks - const blob = new Blob(this.audioChunks, { - type: this.mediaRecorder.mimeType, // likely to be "audio/webm;codecs=opus" in chromium - }); - - // resolve promise - resolve(blob); - }; - // stop recording - this.mediaRecorder.stop(); + recorder.stop(); } catch (e) { + this.mediaRecorder = null; + this.cleanupMediaStream(); reject(e); } }); diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js index 46810f0..91b08b4 100644 --- a/tests/frontend/CallPage.test.js +++ b/tests/frontend/CallPage.test.js @@ -262,6 +262,108 @@ describe("CallPage.vue", () => { expect(stop).toHaveBeenCalled(); }); + it("startWebAudio disables bridge when media devices API is missing", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.config = { telephone_web_audio_enabled: true }; + const updateConfig = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined); + const stopWebAudio = vi.spyOn(wrapper.vm, "stopWebAudio"); + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: undefined, + }); + + try { + await wrapper.vm.startWebAudio(); + expect(wrapper.vm.config.telephone_web_audio_enabled).toBe(false); + expect(updateConfig).toHaveBeenCalledWith({ telephone_web_audio_enabled: false }); + expect(stopWebAudio).toHaveBeenCalled(); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + } + }); + + it("requestAudioPermission returns false when media devices API is missing", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: undefined, + }); + + try { + await expect(wrapper.vm.requestAudioPermission()).resolves.toBe(false); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + } + }); + + it("refreshAudioDevices clears stale devices when media devices API is missing", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "old-in" }]; + wrapper.vm.audioOutputDevices = [{ kind: "audiooutput", deviceId: "old-out" }]; + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: undefined, + }); + + try { + await wrapper.vm.refreshAudioDevices(); + expect(wrapper.vm.audioInputDevices).toEqual([]); + expect(wrapper.vm.audioOutputDevices).toEqual([]); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + } + }); + + it("ensureWebAudio tears down websocket stream when call is no longer active", async () => { + const wrapper = mountCallPage(); + await flushPromises(); + wrapper.vm.config = { telephone_web_audio_enabled: true }; + wrapper.vm.activeCall = null; + const wsClose = vi.fn(); + wrapper.vm.audioWs = { + onopen: vi.fn(), + onmessage: vi.fn(), + onerror: vi.fn(), + onclose: vi.fn(), + close: wsClose, + }; + const sourceDisconnect = vi.fn(); + wrapper.vm.audioSourceNode = { disconnect: sourceDisconnect }; + const processorDisconnect = vi.fn(); + wrapper.vm.audioProcessor = { disconnect: processorDisconnect }; + const stopTrack = vi.fn(); + wrapper.vm.audioStream = { getTracks: () => [{ stop: stopTrack }] }; + const ctxClose = vi.fn().mockResolvedValue(undefined); + wrapper.vm.audioCtx = { state: "running", close: ctxClose }; + + await wrapper.vm.ensureWebAudio({ enabled: true }); + + expect(wsClose).toHaveBeenCalledTimes(1); + expect(sourceDisconnect).toHaveBeenCalledTimes(1); + expect(processorDisconnect).toHaveBeenCalledTimes(1); + expect(stopTrack).toHaveBeenCalledTimes(1); + expect(ctxClose).toHaveBeenCalledTimes(1); + expect(wrapper.vm.audioWs).toBeNull(); + }); + it("getContacts maps telephone contacts list and hydrates visuals", async () => { const wrapper = mountCallPage(); await flushPromises(); diff --git a/tests/frontend/MicrophoneRecorder.test.js b/tests/frontend/MicrophoneRecorder.test.js new file mode 100644 index 0000000..2e345b6 --- /dev/null +++ b/tests/frontend/MicrophoneRecorder.test.js @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import MicrophoneRecorder from "@/js/MicrophoneRecorder"; + +describe("MicrophoneRecorder", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false when mediaDevices API is unavailable", async () => { + const recorder = new MicrophoneRecorder(); + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: undefined, + }); + + try { + await expect(recorder.start()).resolves.toBe(false); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + } + }); + + it("returns false when MediaRecorder is unavailable", async () => { + const recorder = new MicrophoneRecorder(); + const originalMediaRecorder = globalThis.MediaRecorder; + const getUserMedia = vi.fn().mockResolvedValue({ + getTracks: () => [{ stop: vi.fn() }], + }); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia, + }, + }); + + Reflect.deleteProperty(globalThis, "MediaRecorder"); + + try { + await expect(recorder.start()).resolves.toBe(false); + expect(getUserMedia).not.toHaveBeenCalled(); + } finally { + if (typeof originalMediaRecorder === "undefined") { + Reflect.deleteProperty(globalThis, "MediaRecorder"); + } else { + globalThis.MediaRecorder = originalMediaRecorder; + } + } + }); + + it("rejects stop() before start()", async () => { + const recorder = new MicrophoneRecorder(); + await expect(recorder.stop()).rejects.toThrow("Cannot stop recording before start()"); + }); + + it("stops tracks and resolves a blob on successful stop", async () => { + const stopTrack = vi.fn(); + const getUserMedia = vi.fn().mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + }); + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia, + }, + }); + + const originalMediaRecorder = globalThis.MediaRecorder; + class FakeMediaRecorder { + constructor() { + this.mimeType = "audio/webm"; + this.ondataavailable = null; + this.onstop = null; + } + + start() { + if (this.ondataavailable) { + this.ondataavailable({ data: new Blob(["audio"], { type: this.mimeType }) }); + } + } + + stop() { + if (this.onstop) { + this.onstop(); + } + } + } + globalThis.MediaRecorder = FakeMediaRecorder; + const recorder = new MicrophoneRecorder(); + + try { + await expect(recorder.start()).resolves.toBe(true); + const blob = await recorder.stop(); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe("audio/webm"); + expect(stopTrack).toHaveBeenCalledTimes(1); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + if (typeof originalMediaRecorder === "undefined") { + Reflect.deleteProperty(globalThis, "MediaRecorder"); + } else { + globalThis.MediaRecorder = originalMediaRecorder; + } + } + }); +});