From 36fb71699b2311a5280686c74b81799ca68b7683 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 15 Apr 2026 00:26:52 -0400 Subject: [PATCH] feat(voice): dynamic silence threshold + filled red mic halo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/ChatInputBar.vue | 87 +++++++++++++------ .../src/composables/useSilenceDetector.ts | 80 ++++++++++++++--- 2 files changed, 128 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/ChatInputBar.vue b/frontend/src/components/ChatInputBar.vue index 3527969..8779219 100644 --- a/frontend/src/components/ChatInputBar.vue +++ b/frontend/src/components/ChatInputBar.vue @@ -114,15 +114,17 @@ const transcribingVoice = ref(false) const recorder = useVoiceRecorder() const silenceDetector = useSilenceDetector() -// Live mic pulse. `silenceDetector.amplitude` is 0..1 RMS; we floor at 0.1 -// so the button still breathes on silence and cap the scale growth so loud -// input doesn't punch through the input bar. -const micStyle = computed(() => { - if (!recorder.recording.value) return {} - const pulse = 0.1 + Math.min(silenceDetector.amplitude.value, 1) * 0.9 +// Live mic halo. A solid red disc sits behind the mic button and scales +// with `silenceDetector.amplitude` (0..1 RMS, already amplified for +// visibility). The button itself stays put so the mic icon is always +// legible on top. 0.2 baseline breathes on silence; loud speech drives +// the disc out to ~2.6× the button size with a softer radial fade. +const micGlowStyle = computed(() => { + if (!recorder.recording.value) return { display: 'none' } + const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8 return { - transform: `scale(${1 + pulse * 0.18})`, - boxShadow: `0 0 ${6 + pulse * 14}px ${2 + pulse * 4}px rgba(239, 68, 68, ${0.2 + pulse * 0.3})`, + transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`, + opacity: String(0.45 + pulse * 0.4), } }) @@ -242,24 +244,25 @@ defineExpose({ focus, prefill }) class="input-textarea" > - - + +