From 7a939ff4ca5a1f5b3df353d91942cc9ede96475c Mon Sep 17 00:00:00 2001 From: "kushal.saha" Date: Thu, 20 Aug 2026 05:29:14 +0000 Subject: [PATCH] feat(noise-cancellation): implementaed using RNNoise --- .env.example | 2 + package-lock.json | 15 +- package.json | 3 + resources/js/candidate-room.js | 108 +++++-- resources/js/interview-call.js | 69 +++- resources/js/noise-suppressor.js | 297 ++++++++++++++++++ .../interview/candidate-header.blade.php | 10 +- .../candidate/drawing-modal.blade.php | 4 +- .../interview/candidate/ide-header.blade.php | 10 +- .../candidate/incoming-overlay.blade.php | 2 +- .../candidate/notepad-modal.blade.php | 2 +- .../candidate/proctor-sidebar.blade.php | 16 +- 12 files changed, 498 insertions(+), 40 deletions(-) create mode 100644 resources/js/noise-suppressor.js diff --git a/.env.example b/.env.example index 3f030a1..9cea110 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,5 @@ METERED_KEY= # Interview Configurations MAX_INTERVIEWER=2 +INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings +INTERVIEW_RECORDINGS_TEMP_FOLDER=app/temp_recordings diff --git a/package-lock.json b/package-lock.json index e8ea27f..6b88799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,12 @@ { - "name": "singlelogin", + "name": "sls", "lockfileVersion": 3, "requires": true, "packages": { "": { + "dependencies": { + "@sapphi-red/web-noise-suppressor": "^0.4.0" + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "concurrently": "^9.0.1", @@ -407,6 +410,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@sapphi-red/web-noise-suppressor": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@sapphi-red/web-noise-suppressor/-/web-noise-suppressor-0.4.0.tgz", + "integrity": "sha512-vkBEL/VDkbeP3qqSRQFRtPLGa19a38JUzO6J0r5D/MQSumrlERy671DAMaETgY6etXjDCoJcD7JBIaXnuGVtVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", diff --git a/package.json b/package.json index 49c869e..b41d648 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,8 @@ "laravel-vite-plugin": "^3.1", "tailwindcss": "^4.0.0", "vite": "^8.0.0" + }, + "dependencies": { + "@sapphi-red/web-noise-suppressor": "^0.4.0" } } diff --git a/resources/js/candidate-room.js b/resources/js/candidate-room.js index 8ab05ae..8d0dc3a 100644 --- a/resources/js/candidate-room.js +++ b/resources/js/candidate-room.js @@ -7,9 +7,11 @@ import { safePlayMediaStream, getInitials } from './interview-call.js'; import { globalAudioMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; import { initMeteredIceServers } from './metered.js'; +import { createNoiseSuppressedStream, getRNNoisePreference, setRNNoisePreference } from './noise-suppressor.js'; let editor = null; let candidateLocalStream = null; +let candidateNoiseSuppressor = null; let candidatePeerInstance = null; let candidatePeerConnection = null; let liveSyncChannel = null; @@ -765,6 +767,11 @@ export function candidateLeaveCall(isEndedByHost = false) { candidatePeerConnection = null; } + if (candidateNoiseSuppressor) { + try { candidateNoiseSuppressor.dispose(); } catch(e) {} + candidateNoiseSuppressor = null; + } + sendCandidatePeerHeartbeat('leave'); const interviewId = document.body.dataset.interviewId; @@ -968,8 +975,28 @@ export function startCandidateCameraStream() { .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) - .then(stream => { + .then(async rawStream => { if (window.endNativePrompt) window.endNativePrompt(); + + if (candidateNoiseSuppressor) { + try { candidateNoiseSuppressor.dispose(); } catch(e) {} + candidateNoiseSuppressor = null; + } + + let stream = rawStream; + if (rawStream && rawStream.getAudioTracks().length > 0) { + candidateNoiseSuppressor = await createNoiseSuppressedStream(rawStream); + const processedTrack = candidateNoiseSuppressor.getProcessedAudioTrack(); + const videoTracks = rawStream.getVideoTracks(); + const tracks = [...videoTracks]; + if (processedTrack) { + processedTrack.enabled = isCandidateMicEnabled; + tracks.push(processedTrack); + } + stream = new MediaStream(tracks); + updateCandidateNoiseSuppressionUI(candidateNoiseSuppressor.isEnabled()); + } + candidateLocalStream = stream; const vid = document.getElementById('webcam'); if (vid) { @@ -1086,29 +1113,65 @@ function debouncedCandidatePeerHeartbeat() { }, 300); } -export function toggleMic() { - if (!candidateLocalStream) return; - const audioTracks = candidateLocalStream.getAudioTracks(); - if (audioTracks.length > 0) { - isCandidateMicEnabled = !isCandidateMicEnabled; - audioTracks[0].enabled = isCandidateMicEnabled; - const btn = document.getElementById('toggle-mic-btn'); - if (btn) { - btn.innerHTML = ` ${isCandidateMicEnabled ? 'Mic On' : 'Mic Off'}`; - btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; - btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444'; - btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateMicEnabled ? 'bg-emerald-500/20 border-emerald-500 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 border-red-500 text-red-300 hover:bg-red-500/30'} border transition-all cursor-pointer flex items-center justify-center gap-1.5`; +export function updateCandidateNoiseSuppressionUI(isEnabled) { + const btn = document.getElementById('toggle-candidate-noise-btn'); + if (btn) { + const span = btn.querySelector('span'); + if (span) span.textContent = isEnabled ? 'Noise Cancellation Active' : 'Noise Cancellation Deactivated'; + if (isEnabled) { + btn.className = 'w-full mb-2.5 py-1.5 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border-none text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5'; + btn.title = 'Noise Cancellation Active - Click to disable'; + } else { + btn.className = 'w-full mb-2.5 py-1.5 px-2 text-xs font-semibold rounded-lg bg-slate-800/80 border-none text-slate-400 hover:bg-slate-700/80 hover:text-slate-300 transition-all cursor-pointer flex items-center justify-center gap-1.5'; + btn.title = 'Noise Cancellation Disabled - Click to enable'; } - const icon = document.getElementById('cand-self-mic-icon'); - if (icon) { - icon.className = isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; - icon.style.color = isCandidateMicEnabled ? '#34d399' : '#ef4444'; - icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted'; - } - debouncedCandidatePeerHeartbeat(); } } +export function toggleCandidateNoiseSuppression() { + let isEnabled = false; + if (candidateNoiseSuppressor) { + isEnabled = candidateNoiseSuppressor.toggle(); + } else { + isEnabled = !getRNNoisePreference(); + setRNNoisePreference(isEnabled); + } + updateCandidateNoiseSuppressionUI(isEnabled); + return isEnabled; +} + +export function toggleMic() { + isCandidateMicEnabled = !isCandidateMicEnabled; + + if (candidateNoiseSuppressor) { + const processedTrack = candidateNoiseSuppressor.getProcessedAudioTrack(); + if (processedTrack) processedTrack.enabled = isCandidateMicEnabled; + const origTrack = candidateNoiseSuppressor.getOriginalAudioTrack(); + if (origTrack) origTrack.enabled = isCandidateMicEnabled; + } + + if (candidateLocalStream) { + const audioTracks = candidateLocalStream.getAudioTracks(); + if (audioTracks.length > 0) { + audioTracks[0].enabled = isCandidateMicEnabled; + } + } + + const btn = document.getElementById('toggle-mic-btn'); + if (btn) { + btn.innerHTML = ` ${isCandidateMicEnabled ? 'Mic On' : 'Mic Off'}`; + btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; + btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateMicEnabled ? 'bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30'} border-none transition-all cursor-pointer flex items-center justify-center gap-1.5`; + } + const icon = document.getElementById('cand-self-mic-icon'); + if (icon) { + icon.className = isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + icon.style.color = isCandidateMicEnabled ? '#34d399' : '#ef4444'; + icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted'; + } + debouncedCandidatePeerHeartbeat(); +} + export function toggleCam() { const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); if (candidateLocalStream) { @@ -1120,8 +1183,7 @@ export function toggleCam() { if (btn) { btn.innerHTML = ` ${isCandidateCamEnabled ? 'Cam On' : 'Cam Off'}`; btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; - btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444'; - btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateCamEnabled ? 'bg-emerald-500/20 border-emerald-500 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 border-red-500 text-red-300 hover:bg-red-500/30'} border transition-all cursor-pointer flex items-center justify-center gap-1.5`; + btn.className = `flex-1 py-2 px-2 text-xs font-semibold rounded-lg ${isCandidateCamEnabled ? 'bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30'} border-none transition-all cursor-pointer flex items-center justify-center gap-1.5`; } if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex'; } @@ -1479,6 +1541,8 @@ if (typeof window !== 'undefined') { window.candRingtone = candRingtone; window.toggleMic = toggleMic; window.toggleCam = toggleCam; + window.toggleCandidateNoiseSuppression = toggleCandidateNoiseSuppression; + window.updateCandidateNoiseSuppressionUI = updateCandidateNoiseSuppressionUI; window.sendCandidateMessage = sendCandidateMessage; window.pollCandidateChat = pollCandidateChat; window.initCandidateRoom = initCandidateRoom; diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 1e46e88..cdbea41 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -7,6 +7,7 @@ import { initMeteredIceServers } from './metered.js'; import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; import { RecordingCompositor } from './recording-compositor.js'; +import { createNoiseSuppressedStream, getRNNoisePreference, setRNNoisePreference } from './noise-suppressor.js'; // --- Global Audio Ringtone Synthesizer Engine --- export class CallRingtoneEngine { @@ -147,6 +148,7 @@ export function getInterviewerScreenPeerId(userId = null) { : ('interviewer_screen_' + currentInterviewId + '_' + currentUserId); } let interviewerLocalStream = null; +let interviewerNoiseSuppressor = null; let activeCall = null; let isInterviewerMicOn = true; let isInterviewerCamOn = true; @@ -668,6 +670,7 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) { toggleSw.classList.add('on'); } } + updateInterviewerNoiseSuppressionUI(interviewerNoiseSuppressor ? interviewerNoiseSuppressor.isEnabled() : getRNNoisePreference()); const candNameEl = document.getElementById('modal-cand-name'); const candUidEl = document.getElementById('modal-cand-uid'); @@ -1981,7 +1984,25 @@ export function startInterviewerCall(isRejoin = false) { body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin }) }); - const handleStream = (stream) => { + const handleStream = async (rawStream) => { + if (interviewerNoiseSuppressor) { + try { interviewerNoiseSuppressor.dispose(); } catch(e) {} + interviewerNoiseSuppressor = null; + } + + let stream = rawStream; + if (rawStream && rawStream.getAudioTracks().length > 0) { + interviewerNoiseSuppressor = await createNoiseSuppressedStream(rawStream); + const processedTrack = interviewerNoiseSuppressor.getProcessedAudioTrack(); + const videoTracks = rawStream.getVideoTracks(); + const tracks = [...videoTracks]; + if (processedTrack) { + processedTrack.enabled = isInterviewerMicOn; + tracks.push(processedTrack); + } + stream = new MediaStream(tracks); + } + interviewerLocalStream = stream; const selfVid = document.getElementById('interviewer-self-video'); const selfPlace = document.getElementById('self-video-placeholder'); @@ -2006,6 +2027,7 @@ export function startInterviewerCall(isRejoin = false) { if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { recStartBtn.style.display = 'inline-flex'; } + updateInterviewerNoiseSuppressionUI(interviewerNoiseSuppressor ? interviewerNoiseSuppressor.isEnabled() : getRNNoisePreference()); if (interviewerSigChannel) { interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' }); @@ -2140,6 +2162,10 @@ export function leaveInterviewerCall(isEndAll = false) { } catch(e) {} interviewerLocalStream = null; } + if (interviewerNoiseSuppressor) { + try { interviewerNoiseSuppressor.dispose(); } catch(e) {} + interviewerNoiseSuppressor = null; + } if (activeCall) { try { activeCall.close(); } catch(e) {} activeCall = null; @@ -2733,6 +2759,29 @@ export function uploadRecordedBlob(blob, ext = 'webm') { }); } +export function updateInterviewerNoiseSuppressionUI(isEnabled) { + const toggleSw = document.getElementById('noiseCancellationToggle'); + if (toggleSw) { + if (isEnabled) { + toggleSw.classList.add('on'); + } else { + toggleSw.classList.remove('on'); + } + } +} + +export function toggleInterviewerNoiseSuppression() { + let isEnabled = false; + if (interviewerNoiseSuppressor) { + isEnabled = interviewerNoiseSuppressor.toggle(); + } else { + isEnabled = !getRNNoisePreference(); + setRNNoisePreference(isEnabled); + } + updateInterviewerNoiseSuppressionUI(isEnabled); + return isEnabled; +} + export function toggleInterviewerMic() { if (!interviewerLocalStream) return; const audioTracks = interviewerLocalStream.getAudioTracks(); @@ -2741,6 +2790,13 @@ export function toggleInterviewerMic() { isInterviewerMicOn = !isInterviewerMicOn; audioTracks[0].enabled = isInterviewerMicOn; + if (interviewerNoiseSuppressor) { + const procTrack = interviewerNoiseSuppressor.getProcessedAudioTrack(); + if (procTrack) procTrack.enabled = isInterviewerMicOn; + const origTrack = interviewerNoiseSuppressor.getOriginalAudioTrack(); + if (origTrack) origTrack.enabled = isInterviewerMicOn; + } + const btn = document.getElementById('btn-toggle-interviewer-mic'); if (btn) { const icon = btn.querySelector('i'); @@ -2899,6 +2955,8 @@ if (typeof window !== 'undefined') { window.stopCallRecording = stopCallRecording; window.toggleInterviewerMic = toggleInterviewerMic; window.toggleInterviewerCam = toggleInterviewerCam; + window.toggleInterviewerNoiseSuppression = toggleInterviewerNoiseSuppression; + window.updateInterviewerNoiseSuppressionUI = updateInterviewerNoiseSuppressionUI; window.closeReviewModal = closeReviewModal; window.sendSilentWarning = sendSilentWarning; window.setCurrentInterviewId = setCurrentInterviewId; @@ -2909,6 +2967,15 @@ if (typeof window !== 'undefined') { document.addEventListener(evt, () => callRingtone.init(), { passive: true }); }); + const initNoiseState = () => { + updateInterviewerNoiseSuppressionUI(getRNNoisePreference()); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initNoiseState); + } else { + initNoiseState(); + } + globalCallChannel = new BroadcastChannel('global_call_alerts'); globalCallChannel.onmessage = (event) => { const data = event.data; diff --git a/resources/js/noise-suppressor.js b/resources/js/noise-suppressor.js new file mode 100644 index 0000000..cbc38ed --- /dev/null +++ b/resources/js/noise-suppressor.js @@ -0,0 +1,297 @@ +/** + * RNNoise WebAssembly & AudioWorklet Real-Time Noise Suppression Engine + * + * Provides neural-network-powered speech enhancement and background noise suppression + * for WebRTC audio streams (candidate and interviewer calls) using Xiph's RNNoise. + */ + +import { RnnoiseWorkletNode, loadRnnoise } from '@sapphi-red/web-noise-suppressor'; +import rnnoiseWorkletUrl from '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url'; +import rnnoiseWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url'; +import rnnoiseSimdWasmUrl from '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url'; + +// Cache loaded WASM binary and worklet registration across audio contexts +let cachedWasmBinaryPromise = null; +const registeredAudioContexts = new WeakSet(); + +/** + * Check if the browser environment supports AudioWorklet & WebAssembly + */ +export function isRNNoiseSupported() { + return typeof window !== 'undefined' && + typeof window.AudioContext !== 'undefined' && + typeof window.WebAssembly !== 'undefined' && + typeof AudioWorkletNode !== 'undefined'; +} + +/** + * Get user preference for RNNoise noise suppression (default: enabled) + */ +export function getRNNoisePreference() { + if (typeof localStorage === 'undefined') return true; + const pref = localStorage.getItem('sls_rnnoise_enabled'); + return pref === null ? true : pref === 'true'; +} + +/** + * Save user preference for RNNoise noise suppression + */ +export function setRNNoisePreference(enabled) { + if (typeof localStorage !== 'undefined') { + localStorage.setItem('sls_rnnoise_enabled', enabled ? 'true' : 'false'); + } +} + +/** + * Load the RNNoise WASM binary (with SIMD detection fallback) + */ +async function getWasmBinary() { + if (!cachedWasmBinaryPromise) { + cachedWasmBinaryPromise = loadRnnoise({ + url: rnnoiseWasmUrl, + simdUrl: rnnoiseSimdWasmUrl, + }).catch((err) => { + console.warn('[RNNoise] Failed to load WASM binary:', err); + cachedWasmBinaryPromise = null; + throw err; + }); + } + return cachedWasmBinaryPromise; +} + +/** + * Register the RNNoise AudioWorklet module in the given AudioContext + */ +async function registerWorkletModule(audioCtx) { + if (!registeredAudioContexts.has(audioCtx)) { + await audioCtx.audioWorklet.addModule(rnnoiseWorkletUrl); + registeredAudioContexts.add(audioCtx); + } +} + +/** + * Noise Suppressor Controller for an active MediaStream + */ +export class NoiseSuppressorController { + constructor({ + audioCtx, + sourceNode, + rnnoiseNode, + noiseGain, + bypassGain, + destinationNode, + rawStream, + processedStream, + initialEnabled = true, + }) { + this.audioCtx = audioCtx; + this.sourceNode = sourceNode; + this.rnnoiseNode = rnnoiseNode; + this.noiseGain = noiseGain; + this.bypassGain = bypassGain; + this.destinationNode = destinationNode; + this.rawStream = rawStream; + this.processedStream = processedStream; + this._enabled = initialEnabled; + this._disposed = false; + + this.applyState(initialEnabled, true); + } + + /** + * Enable or disable noise suppression with smooth audio gain transition + */ + setEnabled(enabled) { + if (this._disposed) return; + this._enabled = Boolean(enabled); + setRNNoisePreference(this._enabled); + this.applyState(this._enabled, false); + } + + applyState(enabled, immediate = false) { + if (!this.noiseGain || !this.bypassGain || !this.audioCtx) return; + const now = this.audioCtx.currentTime; + const transitionDuration = immediate ? 0 : 0.02; // 20ms click-free cross-fade + + if (enabled) { + // Enable RNNoise path, disable raw bypass + this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now); + this.bypassGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration); + + this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now); + this.noiseGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration); + } else { + // Disable RNNoise path, enable raw bypass + this.noiseGain.gain.setValueAtTime(this.noiseGain.gain.value, now); + this.noiseGain.gain.linearRampToValueAtTime(0.0, now + transitionDuration); + + this.bypassGain.gain.setValueAtTime(this.bypassGain.gain.value, now); + this.bypassGain.gain.linearRampToValueAtTime(1.0, now + transitionDuration); + } + } + + toggle() { + this.setEnabled(!this._enabled); + return this._enabled; + } + + isEnabled() { + return this._enabled; + } + + getProcessedStream() { + return this.processedStream || this.rawStream; + } + + getProcessedAudioTrack() { + if (this.processedStream) { + const tracks = this.processedStream.getAudioTracks(); + if (tracks.length > 0) return tracks[0]; + } + if (this.rawStream) { + const tracks = this.rawStream.getAudioTracks(); + if (tracks.length > 0) return tracks[0]; + } + return null; + } + + getOriginalAudioTrack() { + if (this.rawStream) { + const tracks = this.rawStream.getAudioTracks(); + if (tracks.length > 0) return tracks[0]; + } + return null; + } + + dispose() { + if (this._disposed) return; + this._disposed = true; + + try { + if (this.sourceNode) this.sourceNode.disconnect(); + if (this.rnnoiseNode) { + this.rnnoiseNode.disconnect(); + if (typeof this.rnnoiseNode.destroy === 'function') { + this.rnnoiseNode.destroy(); + } + } + if (this.noiseGain) this.noiseGain.disconnect(); + if (this.bypassGain) this.bypassGain.disconnect(); + if (this.destinationNode) this.destinationNode.disconnect(); + } catch (e) { + console.warn('[RNNoise] Cleanup warning:', e); + } + + if (this.processedStream) { + this.processedStream.getTracks().forEach((t) => { + try { t.stop(); } catch (e) {} + }); + } + + if (this.audioCtx && this.audioCtx.state !== 'closed') { + try { this.audioCtx.close(); } catch (e) {} + } + } +} + +/** + * Fallback Controller when RNNoise is unavailable or fails + */ +class FallbackNoiseSuppressorController { + constructor(rawStream) { + this.rawStream = rawStream; + this._enabled = false; + } + setEnabled(enabled) { this._enabled = Boolean(enabled); } + toggle() { this._enabled = !this._enabled; return this._enabled; } + isEnabled() { return this._enabled; } + getProcessedStream() { return this.rawStream; } + getProcessedAudioTrack() { + return this.rawStream?.getAudioTracks()[0] || null; + } + getOriginalAudioTrack() { + return this.rawStream?.getAudioTracks()[0] || null; + } + dispose() {} +} + +/** + * Create a noise-suppressed MediaStream from a raw microphone MediaStream + * + * @param {MediaStream} rawStream - Input media stream with microphone audio + * @param {Object} options - Optional configuration + * @returns {Promise} Controller with processed stream + */ +export async function createNoiseSuppressedStream(rawStream, options = {}) { + if (!rawStream || !rawStream.getAudioTracks || rawStream.getAudioTracks().length === 0) { + return new FallbackNoiseSuppressorController(rawStream); + } + + if (!isRNNoiseSupported()) { + console.warn('[RNNoise] WebAudio AudioWorklet / WASM not supported in this browser, using standard audio'); + return new FallbackNoiseSuppressorController(rawStream); + } + + try { + const AudioCtxClass = window.AudioContext || window.webkitAudioContext; + // RNNoise is trained on 48,000Hz (48kHz) audio. Create a 48kHz audio context. + const audioCtx = new AudioCtxClass({ sampleRate: 48000 }); + + if (audioCtx.state === 'suspended') { + await audioCtx.resume().catch(() => {}); + } + + // Parallelize WASM binary loading & worklet module registration with timeout + const wasmPromise = getWasmBinary(); + const workletPromise = registerWorkletModule(audioCtx); + + // Fail-safe 3500ms timeout + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('RNNoise init timeout')), 3500) + ); + + const [wasmBinary] = await Promise.race([ + Promise.all([wasmPromise, workletPromise]), + timeoutPromise, + ]); + + const sourceNode = audioCtx.createMediaStreamSource(rawStream); + const rnnoiseNode = new RnnoiseWorkletNode(audioCtx, { + maxChannels: 1, + wasmBinary, + }); + + const noiseGain = audioCtx.createGain(); + const bypassGain = audioCtx.createGain(); + const destinationNode = audioCtx.createMediaStreamDestination(); + + // Connect RNNoise path: Source -> RNNoise -> NoiseGain -> Destination + sourceNode.connect(rnnoiseNode); + rnnoiseNode.connect(noiseGain); + noiseGain.connect(destinationNode); + + // Connect Bypass path: Source -> BypassGain -> Destination + sourceNode.connect(bypassGain); + bypassGain.connect(destinationNode); + + const processedStream = destinationNode.stream; + const initialEnabled = options.enabled !== undefined ? options.enabled : getRNNoisePreference(); + + console.log('[RNNoise] Audio suppressor initialized successfully (enabled:', initialEnabled, ')'); + + return new NoiseSuppressorController({ + audioCtx, + sourceNode, + rnnoiseNode, + noiseGain, + bypassGain, + destinationNode, + rawStream, + processedStream, + initialEnabled, + }); + } catch (err) { + console.warn('[RNNoise] Failed to initialize noise suppression worklet, falling back to raw audio:', err); + return new FallbackNoiseSuppressorController(rawStream); + } +} diff --git a/resources/views/components/interview/candidate-header.blade.php b/resources/views/components/interview/candidate-header.blade.php index a0467da..d621e83 100644 --- a/resources/views/components/interview/candidate-header.blade.php +++ b/resources/views/components/interview/candidate-header.blade.php @@ -31,10 +31,16 @@
- SOS auto-trigger alert - + +
+ +
+ + Noise Cancellation + +
diff --git a/resources/views/components/interview/candidate/drawing-modal.blade.php b/resources/views/components/interview/candidate/drawing-modal.blade.php index e903863..74040c8 100644 --- a/resources/views/components/interview/candidate/drawing-modal.blade.php +++ b/resources/views/components/interview/candidate/drawing-modal.blade.php @@ -6,11 +6,11 @@
- -
diff --git a/resources/views/components/interview/candidate/ide-header.blade.php b/resources/views/components/interview/candidate/ide-header.blade.php index ec05674..6f44a0e 100644 --- a/resources/views/components/interview/candidate/ide-header.blade.php +++ b/resources/views/components/interview/candidate/ide-header.blade.php @@ -14,11 +14,11 @@
- + Notepad - + Drawing Board @@ -34,15 +34,15 @@
- + Run Code - + Submit Solution - + Logout diff --git a/resources/views/components/interview/candidate/incoming-overlay.blade.php b/resources/views/components/interview/candidate/incoming-overlay.blade.php index d29c0b3..a991c13 100644 --- a/resources/views/components/interview/candidate/incoming-overlay.blade.php +++ b/resources/views/components/interview/candidate/incoming-overlay.blade.php @@ -22,7 +22,7 @@
- diff --git a/resources/views/components/interview/candidate/notepad-modal.blade.php b/resources/views/components/interview/candidate/notepad-modal.blade.php index cad9e24..d605ec6 100644 --- a/resources/views/components/interview/candidate/notepad-modal.blade.php +++ b/resources/views/components/interview/candidate/notepad-modal.blade.php @@ -8,7 +8,7 @@ Candidate Notepad (Synced)
- diff --git a/resources/views/components/interview/candidate/proctor-sidebar.blade.php b/resources/views/components/interview/candidate/proctor-sidebar.blade.php index 4401057..0ddbdeb 100644 --- a/resources/views/components/interview/candidate/proctor-sidebar.blade.php +++ b/resources/views/components/interview/candidate/proctor-sidebar.blade.php @@ -67,21 +67,27 @@
- - -
- + + @@ -98,7 +104,7 @@
- + Send