From 33cd61111f047cc412ef51cec5ac948ff1b08206 Mon Sep 17 00:00:00 2001 From: Sambhram Date: Wed, 6 May 2026 14:32:30 +0530 Subject: [PATCH] fix(stage-ui): expose silence duration and correct threshold display (#1774) --- .../src/pages/settings/modules/hearing.vue | 19 ++++++++++- .../components/gadgets/time-series-chart.vue | 3 +- .../stage-ui/src/stores/ai/models/vad.test.ts | 21 ++++++++++++ packages/stage-ui/src/stores/ai/models/vad.ts | 34 ++++++++++++++++--- 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 packages/stage-ui/src/stores/ai/models/vad.test.ts diff --git a/packages/stage-pages/src/pages/settings/modules/hearing.vue b/packages/stage-pages/src/pages/settings/modules/hearing.vue index 5da167eaf..91cef3c2b 100644 --- a/packages/stage-pages/src/pages/settings/modules/hearing.vue +++ b/packages/stage-pages/src/pages/settings/modules/hearing.vue @@ -77,9 +77,14 @@ const testStatusMessage = ref('') const testStreamWasStarted = ref(false) // Track if we started the stream for testing const useVADThreshold = ref(0.6) // 0.1 - 0.9 +const useVADMinSilenceDurationMs = ref(800) const useVADModel = ref(true) // Toggle between VAD and volume-based detection const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value) +function formatVADThreshold(value: number) { + return value.toFixed(2) +} + async function handleSpeechStart() { if (shouldUseStreamInput.value && stream.value) { // Use both callbacks to support incremental updates and final transcript replacement. @@ -119,6 +124,7 @@ const { loading: loadingVAD, } = useVAD(workletUrl, { threshold: useVADThreshold, + minSilenceDurationMs: useVADMinSilenceDurationMs, onSpeechStart: () => { void handleSpeechStart() }, @@ -771,7 +777,17 @@ onUnmounted(() => { :min="0.1" :max="0.9" :step="0.05" - :format-value="value => `${(value * 100).toFixed(0)}%`" + :format-value="formatVADThreshold" + /> + + @@ -845,6 +861,7 @@ onUnmounted(() => { active-legend-label="Voice detected" inactive-legend-label="Silence" threshold-label="Speech threshold" + :format-threshold="formatVADThreshold" /> diff --git a/packages/stage-ui/src/components/gadgets/time-series-chart.vue b/packages/stage-ui/src/components/gadgets/time-series-chart.vue index 9eb586b7c..d47b52777 100644 --- a/packages/stage-ui/src/components/gadgets/time-series-chart.vue +++ b/packages/stage-ui/src/components/gadgets/time-series-chart.vue @@ -38,6 +38,7 @@ interface Props { showActiveIndicator?: boolean // Show active state indicator showLegend?: boolean // Show legend formatValue?: (value: number) => string // Custom value formatter + formatThreshold?: (value: number) => string // Custom threshold formatter } const props = withDefaults(defineProps(), { @@ -361,7 +362,7 @@ const dataAreaPath = computed(() => { {{ inactiveLegendLabel }} - {{ thresholdLabel }}: {{ (threshold * 100).toFixed(0) }}% + {{ thresholdLabel }}: {{ formatThreshold ? formatThreshold(threshold) : `${(threshold * 100).toFixed(0)}%` }} diff --git a/packages/stage-ui/src/stores/ai/models/vad.test.ts b/packages/stage-ui/src/stores/ai/models/vad.test.ts new file mode 100644 index 000000000..20fc3cea6 --- /dev/null +++ b/packages/stage-ui/src/stores/ai/models/vad.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { resolveVADConfig } from './vad' + +describe('resolveVADConfig', () => { + it('uses safer defaults for threshold and silence duration', () => { + expect(resolveVADConfig()).toEqual({ + speechThreshold: 0.6, + exitThreshold: 0.18, + minSilenceDurationMs: 800, + }) + }) + + it('preserves explicit threshold and silence duration values', () => { + expect(resolveVADConfig(0.45, 650)).toEqual({ + speechThreshold: 0.45, + exitThreshold: 0.135, + minSilenceDurationMs: 650, + }) + }) +}) diff --git a/packages/stage-ui/src/stores/ai/models/vad.ts b/packages/stage-ui/src/stores/ai/models/vad.ts index 37105ef00..5cf57b158 100644 --- a/packages/stage-ui/src/stores/ai/models/vad.ts +++ b/packages/stage-ui/src/stores/ai/models/vad.ts @@ -1,3 +1,4 @@ +import type { BaseVADConfig } from '../../../libs/audio/vad' import type { MaybeRefOrGetter } from 'vue' import { merge } from '@moeru/std' @@ -7,14 +8,29 @@ import { createVAD, createVADStates } from '../../../workers/vad' interface UseVADOptions { threshold?: MaybeRefOrGetter + minSilenceDurationMs?: MaybeRefOrGetter onSpeechStart?: () => void onSpeechEnd?: () => void } +const DEFAULT_VAD_THRESHOLD = 0.6 +const DEFAULT_VAD_MIN_SILENCE_DURATION_MS = 800 + +export function resolveVADConfig(threshold?: number, minSilenceDurationMs?: number): Pick { + const resolvedThreshold = threshold ?? DEFAULT_VAD_THRESHOLD + + return { + speechThreshold: resolvedThreshold, + exitThreshold: resolvedThreshold * 0.3, + minSilenceDurationMs: minSilenceDurationMs ?? DEFAULT_VAD_MIN_SILENCE_DURATION_MS, + } +} + export function useVAD(workerUrl: string, options?: UseVADOptions) { const defaultOptions: UseVADOptions = { - threshold: ref(0.6), + threshold: ref(DEFAULT_VAD_THRESHOLD), + minSilenceDurationMs: ref(DEFAULT_VAD_MIN_SILENCE_DURATION_MS), } options = merge(defaultOptions, options) @@ -32,6 +48,7 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) { const loading = ref(false) const threshold = toRef(options.threshold) + const minSilenceDurationMs = toRef(options.minSilenceDurationMs) async function init() { if (loaded.value || loading.value || manager.value) @@ -41,11 +58,11 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) { inferenceError.value = '' try { + const vadConfig = resolveVADConfig(threshold.value, minSilenceDurationMs.value) + vad.value = await createVAD({ sampleRate: 16000, - speechThreshold: threshold.value, - exitThreshold: (threshold.value ?? 0.6) * 0.3, - minSilenceDurationMs: 400, + ...vadConfig, }) // Set up event handlers @@ -119,11 +136,17 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) { } watch(threshold, (newVal) => { - if (vad.value && newVal) { + if (vad.value && newVal !== undefined) { vad.value.updateConfig({ speechThreshold: newVal, exitThreshold: newVal * 0.3 }) } }) + watch(minSilenceDurationMs, (newVal) => { + if (vad.value && newVal !== undefined) { + vad.value.updateConfig({ minSilenceDurationMs: newVal }) + } + }) + return { isSpeech, isSpeechProb, @@ -132,6 +155,7 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) { loading, inferenceError, threshold, + minSilenceDurationMs, init, start,