feat(telephone): improve path discovery and initiation process in TelephoneManager; introduce polling intervals and cancellation checks for improved reliability

This commit is contained in:
Ivan committed 2026-04-15 20:26:07 -05:00
1 parent a5fef35a7f
commit 035fc3f741
5 files changed
+446 -64

No files matched your search

+66 -22
View File
@@ -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 {
+52 -18
View File
@@ -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);
}
});
+102
View File
@@ -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();
+115
View File
@@ -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;
}
}
});
});