feat(noise-cancellation): implementaed using RNNoise

This commit is contained in:
kushal.saha 2026-08-20 05:29:14 +00:00
parent 121b4beaf8
commit 7a939ff4ca
12 changed files with 498 additions and 40 deletions

View File

@ -76,3 +76,5 @@ METERED_KEY=
# Interview Configurations
MAX_INTERVIEWER=2
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings
INTERVIEW_RECORDINGS_TEMP_FOLDER=app/temp_recordings

15
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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,18 +1113,55 @@ function debouncedCandidatePeerHeartbeat() {
}, 300);
}
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';
}
}
}
export function toggleCandidateNoiseSuppression() {
let isEnabled = false;
if (candidateNoiseSuppressor) {
isEnabled = candidateNoiseSuppressor.toggle();
} else {
isEnabled = !getRNNoisePreference();
setRNNoisePreference(isEnabled);
}
updateCandidateNoiseSuppressionUI(isEnabled);
return isEnabled;
}
export function toggleMic() {
if (!candidateLocalStream) return;
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) {
isCandidateMicEnabled = !isCandidateMicEnabled;
audioTracks[0].enabled = isCandidateMicEnabled;
}
}
const btn = document.getElementById('toggle-mic-btn');
if (btn) {
btn.innerHTML = `<i class="${isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}"></i> <span>${isCandidateMicEnabled ? 'Mic On' : 'Mic Off'}</span>`;
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`;
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) {
@ -1107,7 +1171,6 @@ export function toggleMic() {
}
debouncedCandidatePeerHeartbeat();
}
}
export function toggleCam() {
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
@ -1120,8 +1183,7 @@ export function toggleCam() {
if (btn) {
btn.innerHTML = `<i class="${isCandidateCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}"></i> <span>${isCandidateCamEnabled ? 'Cam On' : 'Cam Off'}</span>`;
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;

View File

@ -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;

View File

@ -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<NoiseSuppressorController>} 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);
}
}

View File

@ -31,10 +31,16 @@
<div class="pt-3 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1 text-xs text-slate-300 cursor-pointer select-none" onclick="toggleSosAutoTrigger()">
<span class="flex items-center gap-2 font-medium">
<i class="fa-solid fa-shield-halved text-indigo-400"></i>
<span>SOS auto-trigger alert</span>
</span>
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-indigo-500/20 [&.on]:border-indigo-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-indigo-400" id="sosToggle"></span>
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-emerald-500/20 [&.on]:border-emerald-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-emerald-400" id="sosToggle"></span>
</div>
<div class="flex items-center justify-between px-1 text-xs text-slate-300 cursor-pointer select-none" onclick="toggleInterviewerNoiseSuppression()">
<span class="flex items-center gap-2 font-medium">
<span>Noise Cancellation</span>
</span>
<span class="toggle relative inline-flex h-4 w-8 shrink-0 cursor-pointer rounded-full border border-slate-700 bg-slate-900 transition-colors duration-200 ease-in-out [&.on]:bg-emerald-500/20 [&.on]:border-emerald-500/50 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-2.5 after:w-2.5 after:rounded-full after:bg-slate-500 after:transition-all [&.on]:after:left-4.25 [&.on]:after:bg-emerald-400 on" id="noiseCancellationToggle"></span>
</div>
<div class="grid grid-cols-3 gap-1.5 pt-1">

View File

@ -6,11 +6,11 @@
</div>
<div class="flex items-center gap-2">
<input type="color" id="draw-color" value="#38bdf8" class="w-6 h-6 border-none rounded-full cursor-pointer bg-transparent">
<button type="button" onclick="clearCanvasDraw()" class="px-2.5 py-1 text-[11px] font-semibold bg-white/10 hover:bg-white/20 text-white rounded-md border border-white/10 cursor-pointer transition-colors flex items-center gap-1">
<button type="button" onclick="clearCanvasDraw()" class="px-2.5 py-1 text-[11px] font-semibold bg-white/10 hover:bg-white/20 text-white rounded-md border-none cursor-pointer transition-colors flex items-center gap-1">
<i class="fa-solid fa-eraser text-[10px]"></i>
<span>Clear</span>
</button>
<button type="button" onclick="toggleDrawingModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors">
<button type="button" onclick="toggleDrawingModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors border-none">
<i class="fa-solid fa-xmark"></i>
</button>
</div>

View File

@ -14,11 +14,11 @@
</div>
<div class="flex items-center gap-2.5">
<x-button type="button" onclick="toggleNotepadModal()" variant="secondary" size="sm" icon="fa-solid fa-note-sticky" class="bg-indigo-500/20 text-indigo-300 border-indigo-500/40 hover:bg-indigo-500/30">
<x-button type="button" onclick="toggleNotepadModal()" variant="secondary" size="sm" icon="fa-solid fa-note-sticky" class="bg-indigo-500/20 text-indigo-300 border-none hover:bg-indigo-500/30">
Notepad
</x-button>
<x-button type="button" onclick="toggleDrawingModal()" variant="secondary" size="sm" icon="fa-solid fa-pen-ruler" class="bg-cyan-500/20 text-cyan-300 border-cyan-500/40 hover:bg-cyan-500/30">
<x-button type="button" onclick="toggleDrawingModal()" variant="secondary" size="sm" icon="fa-solid fa-pen-ruler" class="bg-cyan-500/20 text-cyan-300 border-none hover:bg-cyan-500/30">
Drawing Board
</x-button>
@ -34,15 +34,15 @@
</select>
</div>
<x-button type="button" onclick="runCode()" variant="primary" size="sm" icon="fa-solid fa-play" class="shadow-md shadow-indigo-600/30">
<x-button type="button" onclick="runCode()" variant="primary" size="sm" icon="fa-solid fa-play" class="border-none shadow-md shadow-indigo-600/30">
Run Code
</x-button>
<x-button type="button" onclick="submitSolution()" variant="success" size="sm" icon="fa-solid fa-check" class="shadow-md shadow-emerald-600/30">
<x-button type="button" onclick="submitSolution()" variant="success" size="sm" icon="fa-solid fa-check" class="border-none shadow-md shadow-emerald-600/30">
Submit Solution
</x-button>
<x-button type="button" onclick="candidateLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 border-red-500/40 text-red-300 hover:bg-red-500/30">
<x-button type="button" onclick="candidateLogoutAction()" variant="danger" size="sm" icon="fa-solid fa-right-from-bracket" class="bg-red-500/20 border-none text-red-300 hover:bg-red-500/30">
Logout
</x-button>
</div>

View File

@ -22,7 +22,7 @@
</div>
<div class="flex gap-3 justify-center">
<button type="button" onclick="declineCandidateCall()" class="flex-1 py-3 px-4 bg-red-500/20 border border-red-500 text-red-300 font-bold text-xs rounded-xl hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<button type="button" onclick="declineCandidateCall()" class="flex-1 py-3 px-4 bg-red-500/20 border-none text-red-300 font-bold text-xs rounded-xl hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-phone-slash"></i>
<span>Decline / Busy</span>
</button>

View File

@ -8,7 +8,7 @@
<i class="fa-solid fa-note-sticky text-indigo-400"></i>
<span>Candidate Notepad (Synced)</span>
</div>
<button type="button" onclick="toggleNotepadModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors">
<button type="button" onclick="toggleNotepadModal()" class="w-6 h-6 rounded-md bg-slate-700/60 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors border-none">
<i class="fa-solid fa-xmark"></i>
</button>
</div>

View File

@ -67,21 +67,27 @@
</button>
<div class="flex gap-2 mb-2.5">
<button type="button" id="toggle-mic-btn" onclick="toggleMic()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border border-emerald-500 text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<button type="button" id="toggle-mic-btn" onclick="toggleMic()" class="flex-1 py-2 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">
<i class="fa-solid fa-microphone"></i>
<span>Mic On</span>
</button>
<button type="button" id="toggle-cam-btn" onclick="toggleCam()" class="flex-1 py-2 px-2 text-xs font-semibold rounded-lg bg-emerald-500/20 border border-emerald-500 text-emerald-300 hover:bg-emerald-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<button type="button" id="toggle-cam-btn" onclick="toggleCam()" class="flex-1 py-2 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">
<i class="fa-solid fa-video"></i>
<span>Cam On</span>
</button>
<button type="button" id="leave-call-btn" onclick="candidateLeaveCall()" class="py-2 px-2.5 text-xs font-semibold rounded-lg bg-red-500/20 border border-red-500 text-red-300 hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1" title="Leave 2-Way Call">
<button type="button" id="leave-call-btn" onclick="candidateLeaveCall()" class="py-2 px-2.5 text-xs font-semibold rounded-lg bg-red-500/20 border-none text-red-300 hover:bg-red-500/30 transition-all cursor-pointer flex items-center justify-center gap-1" title="Leave 2-Way Call">
<i class="fa-solid fa-phone-slash"></i>
<span>Leave</span>
</button>
</div>
<button type="button" id="share-screen-btn" onclick="startScreenShare()" class="w-full py-2.5 px-3 text-xs font-bold bg-gradient-to-r from-indigo-500 to-sky-600 hover:from-indigo-600 hover:to-sky-700 text-white rounded-lg border-none shadow-md shadow-indigo-500/30 transition-all cursor-pointer flex items-center justify-center gap-1.5">
<!-- Noise Cancellation Toggle -->
<button type="button" id="toggle-candidate-noise-btn" onclick="toggleCandidateNoiseSuppression()" class="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" title="Noise Cancellation - Filter background keyboard and fan noise">
<i class="fa-solid fa-wand-magic-sparkles text-[11px]"></i>
<span>Noise Cancellation: ON</span>
</button>
<button type="button" id="share-screen-btn" onclick="startScreenShare()" class="w-full py-2.5 px-3 text-xs font-bold bg-indigo-500/30 text-white rounded-lg border-none transition-all cursor-pointer flex items-center justify-center gap-1.5">
<i class="fa-solid fa-desktop"></i>
<span>Share Screen with Interviewer</span>
</button>
@ -98,7 +104,7 @@
</div>
<div class="flex gap-1.5">
<input type="text" id="candidate-chat-input" placeholder="Type message to panelist..." onkeydown="if(event.key==='Enter') sendCandidateMessage()" class="flex-1 text-xs bg-slate-950 text-white border border-white/10 rounded-lg px-2.5 py-1.5 outline-none focus:border-indigo-500 placeholder-slate-500">
<x-button type="button" onclick="sendCandidateMessage()" variant="primary" size="sm" icon="fa-solid fa-paper-plane">
<x-button type="button" onclick="sendCandidateMessage()" variant="primary" size="sm" icon="fa-solid fa-paper-plane" class="border-none">
Send
</x-button>
</div>