The web silence detector previously ran RMS over getByteFrequencyData bytes (which are already dB-scaled) and re-log'd the result, producing numbers that didn't line up with real dBFS. Combined with a static -40 dB threshold that sat right on top of room ambient, silence detection rarely fired and the user had to click stop manually. - Rewrite useSilenceDetector to use getFloatTimeDomainData for honest linear RMS → dBFS. - Silence threshold is now dynamic: track session peak dBFS and treat "silent" as 15 dB below peak. Auto-calibrates per mic/room. - Grace period (1500 ms) at start so the user can begin speaking before checks arm; static -45 dB fallback until peak clears -25 dB so dead-silent sessions don't spin forever. - Silence duration bumped 1500 → 2000 ms for breathing room. Visual: detached red radial-gradient disc sits behind the mic in a wrapper; the button no longer scales, so the white mic icon stays legible on top while the halo grows from 1x to ~2.6x with live amplitude. Much more obvious "you are live" signal than the prior subtle box-shadow pulse. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
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<typeof setInterval> | 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 }
|
||
}
|