diff --git a/desktop/app.js b/desktop/app.js index cc42f0d..483ae11 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -817,31 +817,56 @@ async function buildAudioPipeline(rawStream) { state.voice.activeNoiseFilter = 'off'; } } - // Metering/Speaking detection + // Metering/Speaking detection with adaptive threshold and hysteresis. const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; + analyser.fftSize = 1024; processedSource.connect(analyser); - const dataArray = new Uint8Array(analyser.frequencyBinCount); + const timeData = new Float32Array(analyser.fftSize); let localIsSpeaking = false; - let speakCounter = 0; + let speechFrames = 0; + let silenceFrames = 0; + let noiseFloor = 0.004; + let levelEma = 0; const checkVolume = () => { if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - analyser.getByteFrequencyData(dataArray); - let sum = 0; - for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; - const avg = sum / dataArray.length; - const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech + analyser.getFloatTimeDomainData(timeData); - if (isCurrentlySpeaking) { - speakCounter = Math.min(speakCounter + 1, 5); - } else { - speakCounter = Math.max(speakCounter - 1, 0); + let sumSquares = 0; + let peak = 0; + for (let i = 0; i < timeData.length; i++) { + const v = timeData[i]; + sumSquares += v * v; + const abs = Math.abs(v); + if (abs > peak) peak = abs; } - const newSpeakingState = speakCounter >= 2; + const rms = Math.sqrt(sumSquares / timeData.length); + const level = Math.max(rms, peak * 0.5); + levelEma = levelEma * 0.75 + level * 0.25; + + if (!localIsSpeaking) { + // Learn room noise slowly while idle. + noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; + } else { + // Do not let noise floor jump up while speaking. + noiseFloor = Math.min(noiseFloor, levelEma); + } + + const startThreshold = Math.max(noiseFloor * 2.2, 0.010); + const stopThreshold = Math.max(noiseFloor * 1.5, 0.006); + + if (levelEma > startThreshold) { + speechFrames = Math.min(speechFrames + 1, 8); + silenceFrames = 0; + } else if (levelEma < stopThreshold) { + silenceFrames = Math.min(silenceFrames + 1, 8); + speechFrames = Math.max(speechFrames - 1, 0); + } + + const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; if (newSpeakingState !== localIsSpeaking) { localIsSpeaking = newSpeakingState; @@ -858,7 +883,7 @@ async function buildAudioPipeline(rawStream) { myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); } } - setTimeout(checkVolume, 100); + setTimeout(checkVolume, 60); }; checkVolume(); diff --git a/static/app.js b/static/app.js index 9abc973..fa7f533 100644 --- a/static/app.js +++ b/static/app.js @@ -780,31 +780,56 @@ async function buildAudioPipeline(rawStream) { } } - // Metering/Speaking detection + // Metering/Speaking detection with adaptive threshold and hysteresis. const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; + analyser.fftSize = 1024; processedSource.connect(analyser); - const dataArray = new Uint8Array(analyser.frequencyBinCount); + const timeData = new Float32Array(analyser.fftSize); let localIsSpeaking = false; - let speakCounter = 0; + let speechFrames = 0; + let silenceFrames = 0; + let noiseFloor = 0.004; + let levelEma = 0; const checkVolume = () => { if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - analyser.getByteFrequencyData(dataArray); - let sum = 0; - for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; - const avg = sum / dataArray.length; - const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech + analyser.getFloatTimeDomainData(timeData); - if (isCurrentlySpeaking) { - speakCounter = Math.min(speakCounter + 1, 5); - } else { - speakCounter = Math.max(speakCounter - 1, 0); + let sumSquares = 0; + let peak = 0; + for (let i = 0; i < timeData.length; i++) { + const v = timeData[i]; + sumSquares += v * v; + const abs = Math.abs(v); + if (abs > peak) peak = abs; } - const newSpeakingState = speakCounter >= 2; + const rms = Math.sqrt(sumSquares / timeData.length); + const level = Math.max(rms, peak * 0.5); + levelEma = levelEma * 0.75 + level * 0.25; + + if (!localIsSpeaking) { + // Learn room noise slowly while idle. + noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; + } else { + // Do not let noise floor jump up while speaking. + noiseFloor = Math.min(noiseFloor, levelEma); + } + + const startThreshold = Math.max(noiseFloor * 2.2, 0.010); + const stopThreshold = Math.max(noiseFloor * 1.5, 0.006); + + if (levelEma > startThreshold) { + speechFrames = Math.min(speechFrames + 1, 8); + silenceFrames = 0; + } else if (levelEma < stopThreshold) { + silenceFrames = Math.min(silenceFrames + 1, 8); + speechFrames = Math.max(speechFrames - 1, 0); + } + + const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; if (newSpeakingState !== localIsSpeaking) { localIsSpeaking = newSpeakingState; @@ -821,7 +846,7 @@ async function buildAudioPipeline(rawStream) { myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); } } - setTimeout(checkVolume, 100); + setTimeout(checkVolume, 60); }; checkVolume();