import { ref, readonly } from 'vue' export interface SilenceDetectorOptions { /** * Absolute fallback silence threshold in dBFS, used during the first * moments before any real speech has been observed. Once the session peak * clears `dynamicArmDb` we switch to the dynamic threshold instead. */ fallbackThresholdDb?: number // default -45 /** How many dB below the session peak counts as "silent" once armed. */ dropFromPeakDb?: number // default 15 /** * Session peak must reach this level (dBFS) before dynamic thresholding * kicks in. Until then the fallback threshold is used so we don't lock * onto a noise-floor peak. */ dynamicArmDb?: number // default -25 /** How long to wait at the start of recording before running silence checks. */ graceMs?: number // default 1500 silenceDurationMs?: number // default 2000 minRecordingMs?: number // default 500 } /** * Mic silence detector + live amplitude signal. * * Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space, * then derives dBFS for the silence threshold. The previous implementation * ran RMS over `getByteFrequencyData` bytes — those bytes are already a * dB-scaled quantity, so taking their RMS and re-log'ing it produced * numbers that didn't line up with real dBFS and made any static threshold * unpredictable. * * Silence threshold is dynamic: we track the session peak dBFS and treat * "silent" as "current level is at least [dropFromPeakDb] below peak." * This auto-calibrates to whatever mic and room the user is on. Until the * peak climbs above [dynamicArmDb] we fall back to a conservative static * threshold so a quiet room doesn't spin forever. A grace period at the * start of the recording gives the user time to begin speaking before * silence checks arm. * * `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet * speech still visibly moves UI that binds to it. */ export function useSilenceDetector(options: SilenceDetectorOptions = {}) { const { fallbackThresholdDb = -45, dropFromPeakDb = 15, dynamicArmDb = -25, graceMs = 1500, silenceDurationMs = 2000, minRecordingMs = 500, } = options const amplitude = ref(0) let audioCtx: AudioContext | null = null let intervalId: ReturnType | null = null let silenceMs = 0 let startedAt = 0 let peakDb = -100 function start(stream: MediaStream, onSilence: () => void): void { stop() audioCtx = new AudioContext() // Some browsers start AudioContext in "suspended" state — resume so // the analyser returns real values instead of all zeros. audioCtx.resume().catch(() => {}) const source = audioCtx.createMediaStreamSource(stream) const analyser = audioCtx.createAnalyser() analyser.fftSize = 1024 source.connect(analyser) const samples = new Float32Array(analyser.fftSize) silenceMs = 0 startedAt = Date.now() peakDb = -100 intervalId = setInterval(() => { analyser.getFloatTimeDomainData(samples) let sumSq = 0 for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i] const rms = Math.sqrt(sumSq / samples.length) amplitude.value = Math.min(rms * 5, 1) const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100 if (db > peakDb) peakDb = db const elapsed = Date.now() - startedAt if (elapsed < graceMs) return const threshold = peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb if (db < threshold) { silenceMs += 100 if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) { stop() onSilence() } } else { silenceMs = 0 } }, 100) } function stop(): void { if (intervalId !== null) { clearInterval(intervalId) intervalId = null } if (audioCtx) { audioCtx.close().catch(() => {}) audioCtx = null } amplitude.value = 0 silenceMs = 0 peakDb = -100 } return { amplitude: readonly(amplitude), start, stop } }