mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 02:13:56 +00:00
feat(telephone): improve path discovery and initiation process in TelephoneManager; introduce polling intervals and cancellation checks for improved reliability
This commit is contained in:
5 files changed
+446
-64
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user