From 7ad4debe119f6ba4daa369613a2e8b9cc36d6a7a Mon Sep 17 00:00:00 2001 From: Stardust Date: Mon, 30 Mar 2026 03:40:49 +0800 Subject: [PATCH] feat(ui,stage-ui,stage-pages,i18n): transcription confidence filter (#1148) --- packages/i18n/src/locales/en/settings.yaml | 8 ++++ .../i18n/src/locales/zh-Hans/settings.yaml | 6 +++ .../i18n/src/locales/zh-Hant/settings.yaml | 6 +++ .../src/pages/settings/modules/hearing.vue | 30 ++++++++++++- .../src/stores/modules/hearing.test.ts | 31 ++++++++++++++ .../stage-ui/src/stores/modules/hearing.ts | 42 ++++++++++++++++++- .../src/components/form/field/field-range.vue | 6 +-- 7 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 packages/stage-ui/src/stores/modules/hearing.test.ts diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index fa975984d..1e48dbf04 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -404,6 +404,14 @@ pages: section: provider-selection: description: Select the suitable speech recognition provider + confidence-threshold: + title: Confidence Threshold + description: Filter out low-confidence transcriptions to reduce Whisper hallucinations. Values closer to 0 are more strict; drag to the leftmost to disable. Only effective for providers supporting Whisper API (e.g., OpenAI, Groq). + disabled: Disabled + verbose-json-note: >- + Note: If your provider does not support verbose_json responses, this setting will have no effect. + verbose-json-unsupported: >- + Your provider did not return verbose_json segments. Confidence filtering had no effect on the last transcription. memory-long-term: description: Long-term memory specific settings and management title: Long-Term Memory diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 286e88350..c7684467b 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -388,6 +388,12 @@ pages: section: provider-selection: description: 选择合适的语音转文本的服务来源 + confidence-threshold: + title: 置信度阈值 + description: 过滤低置信度的转录结果,避免 Whisper 幻觉噪音。值越接近 0 越严格,拖到最左为禁用。仅对支持 Whisper API 的服务商有效(如 OpenAI、Groq)。 + disabled: 已禁用 + verbose-json-note: 注意:如果你的服务商不支持 verbose_json 响应,此设置将不会生效。 + verbose-json-unsupported: 你的服务商未返回 verbose_json 片段,上次转录的置信度过滤未生效。 memory-long-term: description: 长期记忆 title: 长期记忆 diff --git a/packages/i18n/src/locales/zh-Hant/settings.yaml b/packages/i18n/src/locales/zh-Hant/settings.yaml index 3de11ac64..fcc6345fa 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -379,6 +379,12 @@ pages: section: provider-selection: description: 選擇合適的語音辨識提供者 + confidence-threshold: + title: 置信度閾值 + description: 過濾低置信度的轉錄結果,避免 Whisper 幻覺噪音。值越接近 0 越嚴格,拖到最左為禁用。僅對支援 Whisper API 的服務商有效(如 OpenAI、Groq)。 + disabled: 已停用 + verbose-json-note: 注意:如果你的服務商不支援 verbose_json 響應,此設定將不會生效。 + verbose-json-unsupported: 你的服務商未返回 verbose_json 片段,上次轉錄的置信度過濾未生效。 memory-long-term: description: 長期記憶 title: 長期記憶 diff --git a/packages/stage-pages/src/pages/settings/modules/hearing.vue b/packages/stage-pages/src/pages/settings/modules/hearing.vue index 5ab8c939c..3937b2aa8 100644 --- a/packages/stage-pages/src/pages/settings/modules/hearing.vue +++ b/packages/stage-pages/src/pages/settings/modules/hearing.vue @@ -5,7 +5,7 @@ import { Alert, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple import { useAnalytics, useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables' import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad' import { useAudioContext } from '@proj-airi/stage-ui/stores/audio' -import { useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' +import { CONFIDENCE_THRESHOLD_DISABLED, useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { Button, FieldCheckbox, FieldCombobox, FieldInput, FieldRange } from '@proj-airi/ui' @@ -28,6 +28,8 @@ const { activeCustomModelName, autoSendEnabled, autoSendDelay, + confidenceThreshold, + verboseJsonNotSupported, } = storeToRefs(hearingStore) const providersStore = useProvidersStore() const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore) @@ -656,6 +658,32 @@ onUnmounted(() => { + +
+
+

+ {{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.title') }} +

+
+ {{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.description') }} +
+
+ +
+ {{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.verbose-json-note') }} +
+
+
+ {{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.verbose-json-unsupported') }} +
+
+
diff --git a/packages/stage-ui/src/stores/modules/hearing.test.ts b/packages/stage-ui/src/stores/modules/hearing.test.ts new file mode 100644 index 000000000..654717208 --- /dev/null +++ b/packages/stage-ui/src/stores/modules/hearing.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { filterTranscriptionByConfidence } from './hearing' + +describe('filterTranscriptionByConfidence', () => { + const segments = [ + { text: 'Hello ', avg_logprob: -0.3 }, + { text: 'world ', avg_logprob: -1.2 }, + { text: 'gibberish', avg_logprob: -2.5 }, + ] + + it('keeps all segments when threshold is very low', () => { + expect(filterTranscriptionByConfidence(segments, -3)).toBe('Hello world gibberish') + }) + + it('filters out low-confidence segments', () => { + expect(filterTranscriptionByConfidence(segments, -1)).toBe('Hello') + }) + + it('filters out all segments when threshold is 0', () => { + expect(filterTranscriptionByConfidence(segments, 0)).toBe('') + }) + + it('returns empty string for empty segments', () => { + expect(filterTranscriptionByConfidence([], -1)).toBe('') + }) + + it('trims whitespace from result', () => { + expect(filterTranscriptionByConfidence([{ text: ' hello ', avg_logprob: -0.5 }], -1)).toBe('hello') + }) +}) diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 9898dbcae..554c7999d 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -7,7 +7,7 @@ import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { generateTranscription } from '@xsai/generate-transcription' import { defineStore, storeToRefs } from 'pinia' -import { computed, ref, shallowRef } from 'vue' +import { computed, ref, shallowRef, watch } from 'vue' import vadWorkletUrl from '../../workers/vad/process.worklet?worker&url' @@ -76,6 +76,19 @@ interface HearingTranscriptionInvokeOptions { providerOptions?: Record } +export const CONFIDENCE_THRESHOLD_DISABLED = -3 + +export function filterTranscriptionByConfidence( + segments: Array<{ text?: string, avg_logprob?: number }>, + threshold: number, +): string { + if (!segments.some(s => s?.avg_logprob != null && s?.text != null)) { + return '' + } + + return segments.filter(s => (s?.avg_logprob ?? -Infinity) >= threshold).map(s => s?.text ?? '').join('').trim() +} + const STREAM_TRANSCRIPTION_EXECUTORS: Record = { 'aliyun-nls-transcription': streamAliyunTranscription, // Web Speech API is handled specially in transcribeForMediaStream since it works directly with MediaStream @@ -92,6 +105,12 @@ export const useHearingStore = defineStore('hearing-store', () => { const transcriptionModelSearchQuery = refManualReset('') const autoSendEnabled = useLocalStorageManualReset('settings/hearing/auto-send-enabled', false) const autoSendDelay = useLocalStorageManualReset('settings/hearing/auto-send-delay', 2000) // Default 2 seconds + const confidenceThreshold = useLocalStorageManualReset('settings/hearing/confidence-threshold', CONFIDENCE_THRESHOLD_DISABLED) + const verboseJsonNotSupported = ref(false) + + watch(activeTranscriptionProvider, () => { + verboseJsonNotSupported.value = false + }) // Computed properties const availableProvidersMetadata = computed(() => allAudioTranscriptionProvidersMetadata.value) @@ -154,6 +173,7 @@ export const useHearingStore = defineStore('hearing-store', () => { transcriptionModelSearchQuery.reset() autoSendEnabled.reset() autoSendDelay.reset() + confidenceThreshold.reset() } async function transcription( @@ -217,12 +237,28 @@ export const useHearingStore = defineStore('hearing-store', () => { throw new Error('File input is required for transcription.') } + const useVerboseJson = !format && confidenceThreshold.value > CONFIDENCE_THRESHOLD_DISABLED const response = await generateTranscription({ ...provider.transcription(model, options?.providerOptions), file: normalizedInput.file, - responseFormat: format, + responseFormat: useVerboseJson ? 'verbose_json' : format, }) + if (useVerboseJson) { + if (response.segments) { + verboseJsonNotSupported.value = false + return { + mode: 'generate', + ...response, + text: filterTranscriptionByConfidence(response.segments, confidenceThreshold.value), + } + } + else { + verboseJsonNotSupported.value = true + console.warn('[Hearing] Confidence filter is enabled but the provider did not return verbose_json segments. Filtering has no effect.') + } + } + return { mode: 'generate', ...response, @@ -237,6 +273,8 @@ export const useHearingStore = defineStore('hearing-store', () => { transcriptionModelSearchQuery, autoSendEnabled, autoSendDelay, + confidenceThreshold, + verboseJsonNotSupported, supportsModelListing, providerModels, diff --git a/packages/ui/src/components/form/field/field-range.vue b/packages/ui/src/components/form/field/field-range.vue index bc6776328..b9e1bd6aa 100644 --- a/packages/ui/src/components/form/field/field-range.vue +++ b/packages/ui/src/components/form/field/field-range.vue @@ -36,9 +36,9 @@ const modelValue = defineModel({ required: true })