Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold + filled mic halo' (#35) from dev into main

This commit was merged in pull request #35.
This commit is contained in:
2026-04-15 04:38:27 +00:00
2 changed files with 128 additions and 39 deletions
+62 -25
View File
@@ -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"
></textarea>
<!-- PTT mic -->
<button
v-if="voiceEnabled"
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
:style="micStyle"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<!-- PTT mic (with live amplitude halo behind) -->
<div v-if="voiceEnabled" class="mic-wrapper">
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
<button
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
</svg>
</button>
</button>
</div>
<!-- Abort (streaming) or Send -->
<button
@@ -358,14 +361,48 @@ defineExpose({ focus, prefill })
.btn-mic.mic-recording {
opacity: 1;
color: #ef4444;
border-radius: 50%;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
/* White icon sits on top of the red halo so it stays legible at any
pulse size. */
color: #fff;
position: relative;
z-index: 1;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
absolutely positioned behind the button, scaled/faded by the
computed `micGlowStyle` so the user gets unmistakable feedback
that their voice is being picked up while the mic icon itself
stays put and readable on top. */
.mic-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mic-glow {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(239, 68, 68, 0.9) 0%,
rgba(239, 68, 68, 0.6) 55%,
rgba(239, 68, 68, 0) 100%
);
transform: translate(-50%, -50%) scale(1);
transform-origin: center;
pointer-events: none;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
z-index: 0;
}
.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
position: absolute;
+66 -14
View File
@@ -1,15 +1,54 @@
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
/**
* 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 {
thresholdDb = -40,
silenceDurationMs = 1500,
fallbackThresholdDb = -45,
dropFromPeakDb = 15,
dynamicArmDb = -25,
graceMs = 1500,
silenceDurationMs = 2000,
minRecordingMs = 500,
} = options
@@ -18,31 +57,43 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
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
// getByteFrequencyData returns real values instead of all zeros.
// the analyser returns real values instead of all zeros.
audioCtx.resume().catch(() => {})
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
analyser.fftSize = 1024
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
const samples = new Float32Array(analyser.fftSize)
silenceMs = 0
startedAt = Date.now()
peakDb = -100
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
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 > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
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 && Date.now() - startedAt >= minRecordingMs) {
if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) {
stop()
onSilence()
}
@@ -63,6 +114,7 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
}
amplitude.value = 0
silenceMs = 0
peakDb = -100
}
return { amplitude: readonly(amplitude), start, stop }