feat(ui,stage-ui,stage-pages,i18n): transcription confidence filter (#1148)

This commit is contained in:
Stardust
2026-03-30 03:40:49 +08:00
committed by GitHub
parent 5a11ff7b25
commit 7ad4debe11
7 changed files with 123 additions and 6 deletions
@@ -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
@@ -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: 长期记忆
@@ -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: 長期記憶
@@ -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(() => {
</div>
</div>
<!-- Confidence threshold (only for non-streaming providers) -->
<div v-if="!supportsStreamInput" class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="mb-4">
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-500">
{{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
{{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.description') }}
</div>
</div>
<FieldRange
v-model="confidenceThreshold"
:min="CONFIDENCE_THRESHOLD_DISABLED"
:max="0"
:step="0.1"
:format-value="value => value <= CONFIDENCE_THRESHOLD_DISABLED ? t('settings.pages.modules.hearing.sections.section.confidence-threshold.disabled') : value.toFixed(1)"
/>
<div v-if="confidenceThreshold > CONFIDENCE_THRESHOLD_DISABLED" class="mt-2 text-xs text-neutral-400 dark:text-neutral-500">
{{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.verbose-json-note') }}
</div>
<div v-if="verboseJsonNotSupported" class="mt-2 flex items-center gap-1.5 text-xs text-amber-500 dark:text-amber-400">
<div i-solar:warning-circle-line-duotone class="shrink-0" />
{{ t('settings.pages.modules.hearing.sections.section.confidence-threshold.verbose-json-unsupported') }}
</div>
</div>
<!-- Auto-send settings -->
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="mb-4">
@@ -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')
})
})
@@ -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<string, unknown>
}
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<string, StreamTranscription> = {
'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<string>('')
const autoSendEnabled = useLocalStorageManualReset<boolean>('settings/hearing/auto-send-enabled', false)
const autoSendDelay = useLocalStorageManualReset<number>('settings/hearing/auto-send-delay', 2000) // Default 2 seconds
const confidenceThreshold = useLocalStorageManualReset<number>('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,
@@ -36,9 +36,9 @@ const modelValue = defineModel<number>({ required: true })
<div :class="['flex', 'flex-row', 'items-center', 'gap-2']">
<Range
v-model="modelValue"
:min="min || 0"
:max="max || 1"
:step="step || 0.01"
:min="min ?? 0"
:max="max ?? 1"
:step="step ?? 0.01"
:class="['w-full']"
/>
</div>