diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index abeb991..fb7d64a 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -516,6 +516,12 @@
:label="$t('call.allow_calls_from_contacts_only')"
@update:model-value="toggleAllowCallsFromContactsOnly"
/>
+
+
+
+
+ Microphone
+
+
+
+
+
+ Speaker
+
+
+
+
+
@@ -2113,6 +2179,18 @@ export default {
initiationStatus: null,
initiationTargetHash: null,
initiationTargetName: null,
+ audioWs: null,
+ audioCtx: null,
+ audioStream: null,
+ audioProcessor: null,
+ audioWorkletNode: null,
+ audioSilentGain: null,
+ audioFrameMs: 60,
+ audioInputDevices: [],
+ audioOutputDevices: [],
+ selectedAudioInputId: null,
+ selectedAudioOutputId: null,
+ remoteAudioEl: null,
};
},
computed: {
@@ -2254,6 +2332,7 @@ export default {
this.audioPlayer.pause();
this.audioPlayer = null;
}
+ this.stopWebAudio();
},
methods: {
formatDestinationHash(hash) {
@@ -2271,6 +2350,183 @@ export default {
formatDuration(seconds) {
return Utils.formatMinutesSeconds(seconds);
},
+ async ensureWebAudio(webAudioStatus) {
+ if (!this.config?.telephone_web_audio_enabled) {
+ this.stopWebAudio();
+ return;
+ }
+ if (this.activeCall && webAudioStatus?.enabled) {
+ this.audioFrameMs = webAudioStatus.frame_ms || 60;
+ await this.startWebAudio();
+ } else {
+ this.stopWebAudio();
+ }
+ },
+ async onToggleWebAudio(newVal) {
+ if (!this.config) return;
+ this.config.telephone_web_audio_enabled = newVal;
+ try {
+ await this.updateConfig({ telephone_web_audio_enabled: newVal });
+ if (newVal) {
+ await this.requestAudioPermission();
+ await this.startWebAudio();
+ } else {
+ this.stopWebAudio();
+ }
+ } catch (e) {
+ // revert on failure
+ this.config.telephone_web_audio_enabled = !newVal;
+ }
+ },
+ async startWebAudio() {
+ if (this.audioWs) {
+ return;
+ }
+ try {
+ const constraints = this.selectedAudioInputId
+ ? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
+ : { audio: true };
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
+ this.audioStream = stream;
+
+ if (!this.audioCtx) {
+ this.audioCtx = new AudioContext({ sampleRate: 48000 });
+ }
+
+ const source = this.audioCtx.createMediaStreamSource(stream);
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const url = `${wsProtocol}//${window.location.host}/ws/telephone/audio`;
+
+ const workletLoaded = false; // disabled to avoid CSP violations from blob worklets
+ if (!workletLoaded) {
+ const processor = this.audioCtx.createScriptProcessor(1024, 1, 1);
+ processor.onaudioprocess = (event) => {
+ if (!this.audioWs || this.audioWs.readyState !== WebSocket.OPEN) return;
+ const input = event.inputBuffer.getChannelData(0);
+ const pcm = new Int16Array(input.length);
+ for (let i = 0; i < input.length; i += 1) {
+ pcm[i] = Math.max(-1, Math.min(1, input[i])) * 0x7fff;
+ }
+ this.audioWs.send(pcm.buffer);
+ };
+ source.connect(processor);
+ this.audioProcessor = processor;
+ }
+
+ const ws = new WebSocket(url);
+ ws.binaryType = "arraybuffer";
+ ws.onopen = () => {
+ ws.send(JSON.stringify({ type: "attach" }));
+ };
+ ws.onmessage = (event) => {
+ if (typeof event.data === "string") {
+ return;
+ }
+ this.playRemotePcm(event.data);
+ };
+ ws.onerror = () => {
+ this.stopWebAudio();
+ };
+ ws.onclose = () => {
+ this.stopWebAudio();
+ };
+ this.audioWs = ws;
+ this.refreshAudioDevices();
+ } catch (err) {
+ console.error("Web audio failed", err);
+ ToastUtils.error("Web audio not available");
+ this.stopWebAudio();
+ }
+ },
+ async requestAudioPermission() {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ stream.getTracks().forEach((t) => t.stop());
+ await this.refreshAudioDevices();
+ } catch (e) {
+ console.error("Permission or device request failed", e);
+ }
+ },
+ async refreshAudioDevices() {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
+ this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");
+ if (!this.selectedAudioInputId && this.audioInputDevices.length) {
+ this.selectedAudioInputId = this.audioInputDevices[0].deviceId;
+ }
+ if (!this.selectedAudioOutputId && this.audioOutputDevices.length) {
+ this.selectedAudioOutputId = this.audioOutputDevices[0].deviceId;
+ }
+ } catch (e) {
+ console.error("Failed to enumerate audio devices", e);
+ }
+ },
+ playRemotePcm(arrayBuffer) {
+ if (!this.audioCtx) {
+ return;
+ }
+ const pcm = new Int16Array(arrayBuffer);
+ if (pcm.length === 0) return;
+ const floatBuf = new Float32Array(pcm.length);
+ for (let i = 0; i < pcm.length; i += 1) {
+ floatBuf[i] = pcm[i] / 0x7fff;
+ }
+ const audioBuffer = this.audioCtx.createBuffer(1, floatBuf.length, 48000);
+ audioBuffer.copyToChannel(floatBuf, 0);
+ const bufferSource = this.audioCtx.createBufferSource();
+ bufferSource.buffer = audioBuffer;
+ if (this.selectedAudioOutputId && "setSinkId" in HTMLMediaElement.prototype) {
+ if (!this.remoteAudioEl) {
+ this.remoteAudioEl = new Audio();
+ this.remoteAudioEl.autoplay = true;
+ }
+ const dest = this.audioCtx.createMediaStreamDestination();
+ bufferSource.connect(dest);
+ bufferSource.start();
+ this.remoteAudioEl.srcObject = dest.stream;
+ this.remoteAudioEl
+ .setSinkId(this.selectedAudioOutputId)
+ .catch((err) => console.warn("setSinkId failed", err));
+ } else {
+ bufferSource.connect(this.audioCtx.destination);
+ bufferSource.start();
+ }
+ },
+ stopWebAudio() {
+ if (this.audioProcessor) {
+ try {
+ this.audioProcessor.disconnect();
+ } catch (_) {}
+ this.audioProcessor = null;
+ }
+ if (this.audioStream) {
+ this.audioStream.getTracks().forEach((t) => t.stop());
+ this.audioStream = null;
+ }
+ if (this.audioWs) {
+ try {
+ this.audioWs.close();
+ } catch (_) {}
+ this.audioWs = null;
+ }
+ if (this.remoteAudioEl) {
+ this.remoteAudioEl.srcObject = null;
+ this.remoteAudioEl = null;
+ }
+ if (this.audioWorkletNode) {
+ try {
+ this.audioWorkletNode.disconnect();
+ } catch (_) {}
+ this.audioWorkletNode = null;
+ }
+ if (this.audioSilentGain) {
+ try {
+ this.audioSilentGain.disconnect();
+ } catch (_) {}
+ this.audioSilentGain = null;
+ }
+ },
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
@@ -2326,6 +2582,10 @@ export default {
this.unreadVoicemailsCount = response.data.voicemail.unread_count;
}
+ if (response.data.web_audio) {
+ await this.ensureWebAudio(response.data.web_audio);
+ }
+
// If call just ended, refresh history and show ended state
if (oldCall != null && this.activeCall == null) {
this.getHistory();