Skip to content

Commit 757b5ae

Browse files
authored
Merge pull request #37 from 8JP8/voice-options-and-passkey-fix-16637778461125049411
Add Voice Options and Fix Passkey Persistence
2 parents 1130d9e + f203720 commit 757b5ae

3 files changed

Lines changed: 187 additions & 87 deletions

File tree

backend/utils/session_backup.py

Lines changed: 55 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,78 @@
1-
import base64
2-
import json
3-
import os
41
import time
2+
import logging
53
from typing import Any, Dict, Optional
4+
from datetime import datetime
5+
from flask import current_app
66

7-
8-
def _runtime_dir() -> str:
9-
# Prefer explicit env var, otherwise create a runtime folder beside backend/
10-
base = os.getenv("RUNTIME_DIR")
11-
if base:
12-
return base
13-
return os.path.join(os.path.dirname(os.path.dirname(__file__)), "runtime")
14-
15-
16-
def _path() -> str:
17-
return os.getenv("SESSION_BACKUP_PATH", os.path.join(_runtime_dir(), "session.tmp"))
18-
19-
20-
def _enabled() -> bool:
21-
return os.getenv("SESSION_FILE_BACKUP", "false").lower() == "true"
22-
23-
24-
def _read_all() -> Dict[str, Any]:
25-
p = _path()
26-
if not os.path.exists(p):
27-
return {}
28-
try:
29-
with open(p, "rb") as f:
30-
raw = f.read()
31-
if not raw:
32-
return {}
33-
# Stored as base64(JSON) to avoid plain text; not meant as security.
34-
decoded = base64.b64decode(raw)
35-
return json.loads(decoded.decode("utf-8"))
36-
except Exception:
37-
return {}
38-
39-
40-
def _write_all(obj: Dict[str, Any]) -> None:
41-
p = _path()
42-
os.makedirs(os.path.dirname(p), exist_ok=True)
43-
tmp = p + ".tmp"
44-
data = base64.b64encode(json.dumps(obj, separators=(",", ":")).encode("utf-8"))
45-
with open(tmp, "wb") as f:
46-
f.write(data)
47-
# atomic replace on both Windows + Linux
48-
os.replace(tmp, p)
49-
7+
logger = logging.getLogger(__name__)
508

519
def save_session_backup(session_id: str, data: Dict[str, Any], ttl_seconds: int = 10 * 60) -> None:
5210
"""
53-
Best-effort session backup to a local runtime file.
54-
55-
NOTE: This does NOT replace Redis in Azure multi-instance.
56-
It only helps single-instance deployments or local dev.
11+
Best-effort session backup to MongoDB/CosmosDB.
12+
Persists passkey challenges across deployments by using the database instead of local files.
5713
"""
58-
if not _enabled():
59-
return
6014
if not session_id:
6115
return
62-
now = int(time.time())
63-
all_data = _read_all()
64-
all_data[str(session_id)] = {
65-
"ts": now,
66-
"ttl": int(ttl_seconds),
67-
"data": data,
68-
}
69-
_write_all(all_data)
16+
17+
try:
18+
# Check if we have a database connection
19+
if not current_app or not hasattr(current_app, 'db'):
20+
logger.warning("Session backup skipped: No database connection available")
21+
return
22+
23+
now = int(time.time())
24+
expires_at = now + ttl_seconds
25+
26+
# Use DB upsert
27+
# We store 'expireAt' as a datetime object for potential future TTL index support
28+
# We store 'expires_at' as timestamp for consistent manual checking in load_session_backup
29+
current_app.db.session_backups.update_one(
30+
{'_id': str(session_id)},
31+
{'$set': {
32+
'data': data,
33+
'updated_at': now,
34+
'expires_at': expires_at,
35+
'expireAt': datetime.fromtimestamp(expires_at)
36+
}},
37+
upsert=True
38+
)
39+
except Exception as e:
40+
logger.error(f"Failed to save session backup: {str(e)}")
7041

7142

7243
def load_session_backup(session_id: str) -> Optional[Dict[str, Any]]:
7344
"""Load a session backup if present and not expired."""
74-
if not _enabled():
75-
return None
7645
if not session_id:
7746
return None
78-
now = int(time.time())
79-
all_data = _read_all()
80-
rec = all_data.get(str(session_id))
81-
if not rec:
47+
48+
try:
49+
if not current_app or not hasattr(current_app, 'db'):
50+
return None
51+
52+
now = int(time.time())
53+
54+
# Find document and verify it hasn't expired
55+
backup = current_app.db.session_backups.find_one({
56+
'_id': str(session_id),
57+
'expires_at': {'$gt': now}
58+
})
59+
60+
if backup:
61+
return backup.get('data')
62+
8263
return None
83-
ts = int(rec.get("ts", 0))
84-
ttl = int(rec.get("ttl", 0))
85-
if ttl > 0 and (now - ts) > ttl:
86-
# expired -> delete
87-
try:
88-
del all_data[str(session_id)]
89-
_write_all(all_data)
90-
except Exception:
91-
pass
64+
except Exception as e:
65+
logger.error(f"Failed to load session backup: {str(e)}")
9266
return None
93-
return rec.get("data") or None
9467

9568

9669
def delete_session_backup(session_id: str) -> None:
97-
if not _enabled():
98-
return
70+
"""Delete a session backup."""
9971
if not session_id:
10072
return
101-
all_data = _read_all()
102-
if str(session_id) in all_data:
103-
try:
104-
del all_data[str(session_id)]
105-
_write_all(all_data)
106-
except Exception:
107-
pass
10873

74+
try:
75+
if current_app and hasattr(current_app, 'db'):
76+
current_app.db.session_backups.delete_one({'_id': str(session_id)})
77+
except Exception as e:
78+
logger.error(f"Failed to delete session backup: {str(e)}")

frontend/components/Settings/VoipAudioSettings.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const VoipAudioSettings: React.FC = () => {
1010
selectedDeviceId,
1111
selectMicrophoneDevice,
1212
refreshDevices,
13+
echoCancellation,
14+
noiseSuppression,
15+
setEchoCancellation,
16+
setNoiseSuppression,
1317
} = useVoip();
1418
const { t } = useLanguage();
1519
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
@@ -317,6 +321,51 @@ const VoipAudioSettings: React.FC = () => {
317321
</div>
318322
)}
319323
</div>
324+
325+
{/* Advanced Audio Options */}
326+
<div className="pt-4 border-t theme-border space-y-4">
327+
<h3 className="text-sm font-medium theme-text-primary">
328+
{t('voip.advancedAudio') || 'Advanced Audio Options'}
329+
</h3>
330+
331+
<div className="flex items-center justify-between gap-4">
332+
<div>
333+
<h4 className="font-medium theme-text-primary text-sm">{t('voip.echoCancellation') || 'Echo Cancellation'}</h4>
334+
<p className="text-xs theme-text-secondary">
335+
{t('voip.echoCancellationDesc') || 'Reduces echo from speakers to prevent feedback loops.'}
336+
</p>
337+
</div>
338+
<button
339+
onClick={() => setEchoCancellation(!echoCancellation)}
340+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ml-4 flex-shrink-0 min-w-[44px] ${echoCancellation ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
341+
}`}
342+
>
343+
<span
344+
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${echoCancellation ? 'translate-x-6' : 'translate-x-1'
345+
}`}
346+
/>
347+
</button>
348+
</div>
349+
350+
<div className="flex items-center justify-between gap-4">
351+
<div>
352+
<h4 className="font-medium theme-text-primary text-sm">{t('voip.noiseSuppression') || 'Noise Suppression'}</h4>
353+
<p className="text-xs theme-text-secondary">
354+
{t('voip.noiseSuppressionDesc') || 'Filters out background noise like fans or typing.'}
355+
</p>
356+
</div>
357+
<button
358+
onClick={() => setNoiseSuppression(!noiseSuppression)}
359+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ml-4 flex-shrink-0 min-w-[44px] ${noiseSuppression ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
360+
}`}
361+
>
362+
<span
363+
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${noiseSuppression ? 'translate-x-6' : 'translate-x-1'
364+
}`}
365+
/>
366+
</button>
367+
</div>
368+
</div>
320369
</div>
321370
</div>
322371
);

frontend/contexts/VoipContext.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,13 @@ interface VoipContextType {
6161
availableDevices: MediaDeviceInfo[];
6262
selectedDeviceId: string;
6363
isDocked: boolean;
64+
echoCancellation: boolean;
65+
noiseSuppression: boolean;
6466

6567
// Actions
6668
setIsDocked: (value: boolean) => void;
69+
setEchoCancellation: (value: boolean) => void;
70+
setNoiseSuppression: (value: boolean) => void;
6771
createCall: (roomId: string, roomType: 'group' | 'dm', roomName?: string) => Promise<void>;
6872
joinCall: (callId: string) => Promise<void>;
6973
leaveCall: () => void;
@@ -98,7 +102,11 @@ export const useVoip = () => {
98102
availableDevices: [],
99103
selectedDeviceId: '',
100104
isDocked: false,
105+
echoCancellation: true,
106+
noiseSuppression: true,
101107
setIsDocked: () => { },
108+
setEchoCancellation: () => { },
109+
setNoiseSuppression: () => { },
102110
createCall: async () => { },
103111
joinCall: async () => { },
104112
leaveCall: () => { },
@@ -140,6 +148,8 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
140148
const [availableDevices, setAvailableDevices] = useState<MediaDeviceInfo[]>([]);
141149
const [selectedDeviceId, setSelectedDeviceId] = useState('');
142150
const [isDocked, setIsDocked] = useState(false);
151+
const [echoCancellation, setEchoCancellation] = useState(true);
152+
const [noiseSuppression, setNoiseSuppression] = useState(true);
143153

144154
// State for remote streams (explicit rendering to prevent GC)
145155
const [remoteStreams, setRemoteStreams] = useState<Map<string, MediaStream>>(new Map());
@@ -154,6 +164,8 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
154164

155165
// Refs to track current values for VAD closure
156166
const activeCallRef = useRef<VoipCall | null>(null);
167+
// Track if effect update is needed to avoid initial mount double-trigger
168+
const isFirstMountRef = useRef(true);
157169
const socketRef = useRef(socket);
158170
const connectedRef = useRef(connected);
159171
const userRef = useRef(user);
@@ -176,13 +188,29 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
176188
if (savedThreshold) setMicrophoneThreshold(parseInt(savedThreshold, 10));
177189
const savedDevice = localStorage.getItem('voip_selected_device');
178190
if (savedDevice) setSelectedDeviceId(savedDevice);
191+
192+
const savedEcho = localStorage.getItem('voip_echo_cancellation');
193+
if (savedEcho !== null) setEchoCancellation(savedEcho === 'true');
194+
195+
const savedNoise = localStorage.getItem('voip_noise_suppression');
196+
if (savedNoise !== null) setNoiseSuppression(savedNoise === 'true');
179197
}, []);
180198

181199
const handleSetMicrophoneThreshold = useCallback((value: number) => {
182200
setMicrophoneThreshold(value);
183201
localStorage.setItem('voip_microphone_threshold', value.toString());
184202
}, []);
185203

204+
const handleSetEchoCancellation = useCallback((value: boolean) => {
205+
setEchoCancellation(value);
206+
localStorage.setItem('voip_echo_cancellation', String(value));
207+
}, []);
208+
209+
const handleSetNoiseSuppression = useCallback((value: boolean) => {
210+
setNoiseSuppression(value);
211+
localStorage.setItem('voip_noise_suppression', String(value));
212+
}, []);
213+
186214
const refreshDevices = useCallback(async () => {
187215
try {
188216
const devices = await navigator.mediaDevices.enumerateDevices();
@@ -268,15 +296,22 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
268296
}, []);
269297

270298
const getUserMedia = useCallback(async (deviceId?: string): Promise<MediaStream> => {
271-
const constraints = { audio: deviceId ? { deviceId: { exact: deviceId } } : true, video: false };
299+
const constraints = {
300+
audio: {
301+
deviceId: deviceId ? { exact: deviceId } : undefined,
302+
echoCancellation: echoCancellation,
303+
noiseSuppression: noiseSuppression,
304+
},
305+
video: false
306+
};
272307
try {
273308
return await navigator.mediaDevices.getUserMedia(constraints);
274309
} catch (error: any) {
275310
console.error('[VOIP] getUserMedia failed:', error);
276311
toast.error(t('voip.microphoneError') || 'Microphone error');
277312
throw error;
278313
}
279-
}, [t]);
314+
}, [t, echoCancellation, noiseSuppression]);
280315

281316
const createPeerConnection = useCallback((targetUserId: string): RTCPeerConnection => {
282317
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
@@ -563,6 +598,48 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
563598
}
564599
}, [activeCall, getUserMedia, stopVoiceActivityDetection, startVoiceActivityDetection]);
565600

601+
// Handle changes to echo/noise settings while active
602+
useEffect(() => {
603+
if (isFirstMountRef.current) {
604+
isFirstMountRef.current = false;
605+
return;
606+
}
607+
608+
// Only trigger if we have an active stream and device is selected
609+
if (localStreamRef.current && selectedDeviceId) {
610+
const updateStream = async () => {
611+
console.log('[VOIP] Updating audio constraints...', { echoCancellation, noiseSuppression });
612+
try {
613+
const newStream = await getUserMedia(selectedDeviceId);
614+
615+
// Replace tracks in all peer connections
616+
const audioTrack = newStream.getAudioTracks()[0];
617+
peerConnectionsRef.current.forEach(({ connection }) => {
618+
const sender = connection.getSenders().find(s => s.track?.kind === 'audio');
619+
if (sender) {
620+
sender.replaceTrack(audioTrack);
621+
}
622+
});
623+
624+
// Stop old tracks
625+
if (localStreamRef.current) {
626+
localStreamRef.current.getTracks().forEach(track => track.stop());
627+
}
628+
629+
localStreamRef.current = newStream;
630+
631+
// Restart VAD with new stream
632+
stopVoiceActivityDetection();
633+
startVoiceActivityDetection(newStream);
634+
} catch (error) {
635+
console.error('[VOIP] Failed to update audio constraints:', error);
636+
}
637+
};
638+
639+
updateStream();
640+
}
641+
}, [echoCancellation, noiseSuppression, selectedDeviceId, getUserMedia, startVoiceActivityDetection, stopVoiceActivityDetection]);
642+
566643

567644

568645

@@ -1000,6 +1077,10 @@ export const VoipProvider: React.FC<VoipProviderProps> = ({ children }) => {
10001077
refreshDevices,
10011078
isDocked,
10021079
setIsDocked,
1080+
echoCancellation,
1081+
noiseSuppression,
1082+
setEchoCancellation: handleSetEchoCancellation,
1083+
setNoiseSuppression: handleSetNoiseSuppression,
10031084
};
10041085

10051086
return (

0 commit comments

Comments
 (0)