Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcbe018297 | |||
| 36fb71699b |
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user