feat(noise-cancellation): implementaed using RNNoise
This commit is contained in:
parent
121b4beaf8
commit
7a939ff4ca
@ -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
15
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = `<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`;
|
||||
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 = `<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.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 = `<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;
|
||||
|
||||
@ -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;
|
||||
|
||||
297
resources/js/noise-suppressor.js
Normal file
297
resources/js/noise-suppressor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user