feat(stage-pages): add Hearing playground (#2265)

This commit is contained in:
Neko
2026-08-12 05:27:59 +08:00
committed by GitHub
parent ac0192e5b6
commit 57397889a5
8 changed files with 501 additions and 481 deletions
@@ -686,6 +686,19 @@ pages:
section:
provider-selection:
description: Select the suitable speech recognition provider
playground:
current: Current transcript
description: Listen through the active Hearing pipeline and keep each completed speech segment below.
empty: Start monitoring and speak into the selected microphone.
error-title: Transcription error
listening: Listening for speech…
no-transcription: No speech was recognized in this audio.
segment: 'Speech segment {number}'
start: Start monitoring
stop: Stop monitoring
title: Hearing playground
transcribing: Transcribing…
transcription-failed: Transcription failed.
confidence-threshold:
title: Confidence Threshold
description: >-
@@ -657,6 +657,19 @@ pages:
section:
provider-selection:
description: 选择合适的语音转文本的服务来源
playground:
current: 当前转写
description: 通过当前的听觉管线监听,并在下方保留每一段完成的语音转写。
empty: 开始监听,然后对着所选麦克风说话。
error-title: 转写错误
listening: 正在等待语音……
no-transcription: 此段音频中未识别到语音。
segment: '语音片段 {number}'
start: 开始监听
stop: 停止监听
title: 听觉测试面板
transcribing: 正在转写……
transcription-failed: 转写失败。
confidence-threshold:
title: 置信度阈值
description: >-
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { HearingPlaygroundSegment } from '@proj-airi/stage-ui/composables'
import { useObjectUrl } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
number: number
segment: HearingPlaygroundSegment
}>()
const { t } = useI18n()
const recording = computed(() => props.segment.recording)
const audioUrl = useObjectUrl(recording)
</script>
<template>
<li
:class="[
'flex flex-col gap-3',
'px-3 py-2',
]"
data-testid="hearing-playground-segment"
:data-status="segment.status"
>
<audio
v-if="audioUrl"
:src="audioUrl"
:aria-label="t('settings.pages.modules.hearing.sections.section.playground.segment', { number })"
controls
:class="['mb-2', 'w-full']"
/>
<div :class="['flex items-center gap-2', 'text-xs text-neutral-400 dark:text-neutral-500']">
<span>{{ t('settings.pages.modules.hearing.sections.section.playground.segment', { number }) }}</span>
<span
v-if="segment.status === 'transcribing'"
:class="['flex items-center gap-1', 'text-primary-500 dark:text-primary-400']"
>
<span class="animate-spin" i-solar:spinner-line-duotone />
{{ t('settings.pages.modules.hearing.sections.section.playground.transcribing') }}
</span>
</div>
<p
v-if="segment.status === 'complete'"
:class="['whitespace-pre-wrap', 'text-sm text-neutral-700 dark:text-neutral-200']"
data-testid="hearing-playground-transcript"
>
{{ segment.text }}
</p>
<p v-else-if="segment.status === 'empty'" :class="['text-sm text-neutral-400 italic', 'dark:text-neutral-500']">
{{ t('settings.pages.modules.hearing.sections.section.playground.no-transcription') }}
</p>
<p v-else-if="segment.status === 'error'" :class="['text-sm text-red-500', 'dark:text-red-400']">
{{ segment.error || t('settings.pages.modules.hearing.sections.section.playground.transcription-failed') }}
</p>
</li>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { HearingPlaygroundSegment } from '@proj-airi/stage-ui/composables'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HearingPlaygroundSegmentItem from './hearing-playground-segment.vue'
const props = defineProps<{
current: string
isMonitoring: boolean
segments: readonly HearingPlaygroundSegment[]
}>()
const { t } = useI18n()
const reversedSegments = computed(() => props.segments.toReversed())
</script>
<template>
<div
:class="['mb-4 min-h-28', 'rounded-xl']"
data-testid="hearing-playground-transcripts"
aria-live="polite"
>
<ol
v-if="current || segments.length"
v-auto-animate
:class="[
'max-h-xs flex flex-col overflow-y-auto',
'rounded-xl bg-neutral-100 dark:bg-neutral-900',
]"
>
<li
v-if="current"
:class="[
'rounded-lg px-3 py-2',
'bg-primary-50 dark:bg-primary-900/20',
]"
data-testid="hearing-playground-current"
>
<div :class="['mb-1', 'text-xs text-primary-600 font-medium dark:text-primary-400']">
{{ t('settings.pages.modules.hearing.sections.section.playground.current') }}
</div>
<p :class="['whitespace-pre-wrap', 'text-sm text-neutral-700 dark:text-neutral-200']">
{{ current }}
</p>
</li>
<HearingPlaygroundSegmentItem
v-for="(segment, index) in reversedSegments"
:key="segment.id"
:number="segments.length - index"
:segment="segment"
/>
</ol>
<div
v-else-if="isMonitoring"
:class="[
'min-h-20 flex items-center justify-center gap-2',
'text-sm text-neutral-400 dark:text-neutral-500',
]"
>
<div class="animate-pulse" i-solar:microphone-3-line-duotone />
{{ t('settings.pages.modules.hearing.sections.section.playground.listening') }}
</div>
<div
v-else
:class="[
'min-h-20 flex items-center justify-center text-center',
'text-sm text-neutral-400 dark:text-neutral-500',
]"
>
{{ t('settings.pages.modules.hearing.sections.section.playground.empty') }}
</div>
</div>
</template>
@@ -1,21 +1,19 @@
<script setup lang="ts">
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { errorMessageFrom } from '@moeru/std'
import { Alert, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAnalytics, useAudioAnalyzer, useAudioRecorder, useVoiceInputSession } from '@proj-airi/stage-ui/composables'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useAnalytics, useAudioAnalyzer, useHearingPlaygroundSegments, useVoiceInputSession } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { CONFIDENCE_THRESHOLD_DISABLED, useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { Button, FieldCheckbox, FieldCombobox, FieldInput, FieldRange } from '@proj-airi/ui'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import HearingPlaygroundTranscripts from './components/hearing-playground-transcripts.vue'
const { t } = useI18n()
const hearingStore = useHearingStore()
@@ -41,172 +39,99 @@ const { trackProviderClick } = useAnalytics()
const settingsAudioDeviceStore = useSettingsAudioDevice()
const { askPermission, stopStream, startStream } = settingsAudioDeviceStore
const { audioInputOptions, selectedAudioInput, stream } = storeToRefs(settingsAudioDeviceStore)
const { discardRecord, startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
const { startAnalyzer, stopAnalyzer, onAnalyzerUpdate, volumeLevel } = useAudioAnalyzer()
const { audioContext } = storeToRefs(useAudioContext())
const hearingSpeechInputPipeline = useHearingSpeechInputPipeline()
const {
removeStreamingTranscriptionConsumer,
transcribeForRecording,
transcribeForMediaStream,
removeStreamingTranscriptionConsumer,
stopStreamingTranscription,
} = hearingSpeechInputPipeline
const {
supportsStreamInput,
error: transcriptionPipelineError,
} = storeToRefs(hearingSpeechInputPipeline)
const hearingPlaygroundTranscriptionConsumerId = 'hearing-playground'
/** Identifies monitoring callbacks in the shared streaming transcription session. */
const monitoringTranscriptionConsumerId = 'hearing-settings:monitoring'
/** Identifies test callbacks in the shared streaming transcription session. */
const testTranscriptionConsumerId = 'hearing-settings:test'
// This page owns one monitoring session. Setup can restart it after device or Provider changes,
// and stop releases the recorder, Provider consumer, media stream, and analyzer in that order.
let volumeSpeechEndTimer: ReturnType<typeof setTimeout> | undefined
const animationFrame = ref<number>()
const error = shallowRef('')
const isMonitoring = shallowRef(false)
const error = ref<string>('')
const isMonitoring = ref(false)
const {
current: currentTranscription,
segments: playgroundSegments,
replaceStreamingText,
finishStreaming,
startRecording,
finishRecording,
finishEmpty,
finishError,
clear: clearPlaygroundSegments,
} = useHearingPlaygroundSegments()
const transcriptions = ref<string[]>([])
const audios = ref<Blob[]>([])
const audioCleanups = ref<(() => void)[]>([])
const audioURLs = computed(() => {
return audios.value.map((blob) => {
const url = URL.createObjectURL(blob)
audioCleanups.value.push(() => URL.revokeObjectURL(url))
return url
const useVADThreshold = shallowRef(0.6) // 0.1 - 0.9
const useVolumeThreshold = shallowRef(10) // 1 - 80
const useVADMinSilenceDurationMs = shallowRef(800)
const useVADModel = shallowRef(true) // Toggle between VAD and volume-based detection
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
const sortedProviderModels = computed(() => {
return providerModels.value.toSorted((left, right) => {
if (left.id === activeTranscriptionModel.value)
return -1
if (right.id === activeTranscriptionModel.value)
return 1
return 0
})
})
// Speech-to-Text test state
const isTestingSTT = ref(false)
const testTranscriptionText = ref<string>('')
const testTranscriptionError = ref<string>('')
const isTranscribing = ref(false)
const testStreamingText = ref<string>('')
const testStatusMessage = ref<string>('')
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)
let sttTestStopTimer: ReturnType<typeof setTimeout> | undefined
const sttTestVoiceInputSession = useVoiceInputSession(stream, {
shouldUseStreamInput,
// Manual Settings tests own their 3s recording window; automatic volume segmentation
// would race that timer and make provider diagnostics harder to interpret.
volumeFallback: {
enabled: false,
},
onSegmentStart: () => {
testStatusMessage.value = 'Recording audio for transcription... (3 seconds)'
},
onTranscriptionStart: () => {
testStatusMessage.value = 'Transcribing recording...'
isTranscribing.value = true
},
onTranscriptionResult: ({ text }) => {
testTranscriptionText.value = text
testStatusMessage.value = 'Transcription complete!'
isTranscribing.value = false
isTestingSTT.value = false
console.info('STT test transcription result:', text)
},
onTranscriptionEmpty: () => {
testTranscriptionError.value = transcriptionPipelineError.value || 'No transcription result returned from provider'
testStatusMessage.value = 'Transcription failed'
isTranscribing.value = false
isTestingSTT.value = false
},
onRecordingSkipped: ({ gate }) => {
testTranscriptionError.value = gate?.reason || transcriptionPipelineError.value || 'No recording captured from microphone'
testStatusMessage.value = 'Transcription failed'
isTranscribing.value = false
isTestingSTT.value = false
},
onTranscriptionError: ({ error }) => {
testTranscriptionError.value = errorMessageFromValue(error)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
isTranscribing.value = false
isTestingSTT.value = false
console.error('STT test transcription error:', error)
},
})
async function resetSttTestVoiceInputSession() {
if (sttTestStopTimer) {
clearTimeout(sttTestStopTimer)
sttTestStopTimer = undefined
}
await sttTestVoiceInputSession.stop({ flushActiveRecording: false })
}
function formatVADThreshold(value: number) {
return value.toFixed(2)
}
async function handleSpeechStart() {
if (isTestingSTT.value)
return
if (shouldUseStreamInput.value && stream.value) {
// Use both callbacks to support incremental updates and final transcript replacement.
// ChatArea uses only onSentenceEnd to avoid re-adding deleted text.
await transcribeForMediaStream(stream.value, {
consumerId: monitoringTranscriptionConsumerId,
onSentenceEnd: (delta) => {
transcriptions.value.push(delta)
},
onSpeechEnd: (text) => {
transcriptions.value = [text]
},
})
return
}
startRecord()
}
async function handleSpeechEnd() {
if (isTestingSTT.value)
return
if (shouldUseStreamInput.value) {
// For streaming providers, keep the session alive; idle timer will handle teardown.
return
}
stopRecord()
}
const {
init: initVAD,
dispose: disposeVAD,
isSpeech: isSpeechVAD,
isSpeechVAD,
isSpeechProb,
isSpeechHistory,
inferenceError: vadModelError,
start: startVAD,
loaded: loadedVAD,
loading: loadingVAD,
} = useVAD(workletUrl, {
threshold: useVADThreshold,
minSilenceDurationMs: useVADMinSilenceDurationMs,
onSpeechStart: () => {
void handleSpeechStart()
vadError: vadModelError,
vadLoaded: loadedVAD,
vadLoading: loadingVAD,
startSegment,
stopSegment,
startAutoSegmentation,
stop: stopVoiceInputSession,
} = useVoiceInputSession(stream, {
shouldUseStreamInput,
vad: {
threshold: useVADThreshold,
minSilenceDurationMs: useVADMinSilenceDurationMs,
},
onSpeechEnd: () => {
void handleSpeechEnd()
volumeFallback: {
enabled: false,
},
onSpeechCancel: () => {
if (!isTestingSTT.value && !shouldUseStreamInput.value)
void discardRecord()
onRecordingReady: ({ recording }) => {
if (!recording)
return
return startRecording(recording)
},
onTranscriptionResult: ({ metadata, text }) => {
finishRecording(metadata, text)
error.value = ''
},
onTranscriptionEmpty: ({ metadata }) => {
finishEmpty(metadata)
},
onTranscriptionError: ({ metadata, error: cause }) => {
const message = errorMessageFrom(cause) ?? t('settings.pages.modules.hearing.sections.section.playground.transcription-failed')
finishError(metadata, message)
error.value = message
},
})
const isSpeechVolume = ref(false) // Volume-based speaking detection
const isSpeechVolume = shallowRef(false) // Volume-based speaking detection
const isSpeech = computed(() => {
if (useVADModel.value && loadedVAD.value) {
return isSpeechVAD.value
@@ -219,7 +144,7 @@ async function setupAudioMonitoring() {
try {
if (!selectedAudioInput.value) {
console.warn('No audio input device selected')
return
return false
}
await stopAudioMonitoring()
@@ -227,7 +152,17 @@ async function setupAudioMonitoring() {
await startStream()
if (!stream.value) {
console.warn('No audio stream available')
return
return false
}
if (supportsStreamInput.value) {
// The Hearing pipeline owns speech segmentation and the provider session.
// The page VAD below only drives the visualization for streaming providers.
await transcribeForMediaStream(stream.value, {
consumerId: hearingPlaygroundTranscriptionConsumerId,
onSpeechEnd: finishStreaming,
onTranscriptionUpdate: replaceStreamingText,
})
}
const source = audioContext.value.createMediaStreamSource(stream.value)
@@ -236,44 +171,46 @@ async function setupAudioMonitoring() {
const analyzer = startAnalyzer(audioContext.value)
onAnalyzerUpdate((volumeLevel) => {
if (!useVADModel.value || !loadedVAD.value) {
isSpeechVolume.value = volumeLevel > useVADThreshold.value
isSpeechVolume.value = volumeLevel > useVolumeThreshold.value
}
})
if (analyzer)
source.connect(analyzer)
if (useVADModel.value) {
await initVAD()
await startVAD(stream.value)
await startAutoSegmentation()
}
error.value = ''
return true
}
catch (error) {
console.error('Error setting up audio monitoring:', error)
vadModelError.value = errorMessageFromValue(error)
catch (cause) {
console.error('Error setting up audio monitoring:', cause)
error.value = errorMessageFrom(cause) ?? t('settings.pages.modules.hearing.sections.section.playground.transcription-failed')
return false
}
}
async function stopAudioMonitoring() {
if (animationFrame.value) { // Stop animation frame
cancelAnimationFrame(animationFrame.value)
animationFrame.value = undefined
async function stopAudioMonitoring(disposeProviderId = activeTranscriptionProvider.value) {
if (volumeSpeechEndTimer) {
clearTimeout(volumeSpeechEndTimer)
volumeSpeechEndTimer = undefined
}
removeStreamingTranscriptionConsumer(monitoringTranscriptionConsumerId)
await stopStreamingTranscription(true, activeTranscriptionProvider.value)
await stopVoiceInputSession({ flushActiveRecording: false })
removeStreamingTranscriptionConsumer(hearingPlaygroundTranscriptionConsumerId)
await stopStreamingTranscription(true, disposeProviderId)
if (stream.value) { // Stop media stream
stopStream()
}
stopAnalyzer()
disposeVAD()
}
// Monitoring toggle
async function toggleMonitoring() {
if (!isMonitoring.value) {
await setupAudioMonitoring()
isMonitoring.value = true
isMonitoring.value = await setupAudioMonitoring()
}
else {
await stopAudioMonitoring()
@@ -333,210 +270,56 @@ function syncOpenAICompatibleSettings() {
}
}
onStopRecord(async (recording) => {
if (shouldUseStreamInput.value)
watch([selectedAudioInput, useVADModel], async () => {
if (!isMonitoring.value)
return
if (isTestingSTT.value)
return
if (!recording || recording.size === 0)
return
// Normal monitoring mode - add to audios and transcribe
audios.value.push(recording)
const res = await transcribeForRecording(recording)
if (res) {
transcriptions.value.push(res)
error.value = ''
}
else if (transcriptionPipelineError.value) {
error.value = transcriptionPipelineError.value
}
isMonitoring.value = await setupAudioMonitoring()
})
// Speech-to-Text test functions
async function startSTTTest() {
if (!activeTranscriptionProvider.value) {
testTranscriptionError.value = 'Please select a transcription provider first'
watch(isSpeechVolume, (speaking) => {
if (useVADModel.value)
return
if (volumeSpeechEndTimer) {
clearTimeout(volumeSpeechEndTimer)
volumeSpeechEndTimer = undefined
}
if (speaking) {
void startSegment('volume')
return
}
if (!selectedAudioInput.value) {
testTranscriptionError.value = 'Please select an audio input device first'
return
volumeSpeechEndTimer = setTimeout(() => {
volumeSpeechEndTimer = undefined
if (!useVADModel.value && !isSpeechVolume.value)
void stopSegment('volume')
}, useVADMinSilenceDurationMs.value)
})
watch(activeTranscriptionProvider, async (provider, previousProvider) => {
const shouldRestartMonitoring = isMonitoring.value
if (shouldRestartMonitoring) {
isMonitoring.value = false
await stopAudioMonitoring(previousProvider)
}
testTranscriptionError.value = ''
testTranscriptionText.value = ''
testStreamingText.value = ''
testStatusMessage.value = ''
error.value = ''
isTestingSTT.value = true
isTranscribing.value = true
clearPlaygroundSegments()
try {
// Ensure audio stream is available
if (!stream.value) {
testStatusMessage.value = 'Starting audio stream...'
testStreamWasStarted.value = true
await startStream()
// Wait for the stream to become available with a 3-second timeout.
try {
await until(stream).toBeTruthy({ timeout: 3000, throwOnTimeout: true })
}
catch {
handleStreamStartError()
return
}
// Type guard: until guarantees stream.value is truthy, but TypeScript doesn't know this
if (!stream.value) {
handleStreamStartError()
return
}
}
else {
testStreamWasStarted.value = false // Stream was already running
}
// Check if provider supports streaming input
if (shouldUseStreamInput.value && stream.value) {
testStatusMessage.value = 'Starting streaming transcription...'
console.info('Starting STT test with streaming input for provider:', activeTranscriptionProvider.value)
await transcribeForMediaStream(stream.value, {
consumerId: testTranscriptionConsumerId,
onSentenceEnd: (delta) => {
if (delta && delta.trim()) {
testStreamingText.value += `${delta} `
testStatusMessage.value = 'Transcribing... (streaming)'
isTranscribing.value = true
console.info('STT test received sentence:', delta)
}
},
onSpeechEnd: (text) => {
if (text) {
testTranscriptionText.value = text
testStreamingText.value = ''
testStatusMessage.value = 'Transcription complete!'
isTranscribing.value = false
console.info('STT test completed with text:', text)
}
else {
testStatusMessage.value = 'Waiting for speech...'
isTranscribing.value = false
}
},
})
testStatusMessage.value = 'Listening for speech... (streaming mode active)'
isTranscribing.value = false // Not actively transcribing yet, just listening
}
else {
// Fallback to recording-based transcription
testStatusMessage.value = 'Recording audio for transcription... (3 seconds)'
console.info('Starting STT test with recording-based transcription for provider:', activeTranscriptionProvider.value)
const recordingStarted = await sttTestVoiceInputSession.startSegment('manual')
if (!recordingStarted) {
if (!testTranscriptionError.value)
testStatusMessage.value = 'Recording did not start'
isTranscribing.value = false
isTestingSTT.value = false
return
}
// Wait a bit for recording to start, then stop it after a delay
sttTestStopTimer = setTimeout(async () => {
sttTestStopTimer = undefined
testStatusMessage.value = 'Processing transcription...'
try {
await sttTestVoiceInputSession.stopSegment('manual')
}
catch (err) {
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
isTranscribing.value = false
isTestingSTT.value = false
console.error('STT test stop timer error:', err)
}
}, 3000) // Record for 3 seconds
}
}
catch (err) {
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
isTranscribing.value = false
isTestingSTT.value = false
console.error('STT test error:', err)
}
}
async function stopSTTTest() {
isTestingSTT.value = false
isTranscribing.value = false
testStatusMessage.value = 'Stopped'
removeStreamingTranscriptionConsumer(testTranscriptionConsumerId)
try {
// Stop streaming transcription if active
if (shouldUseStreamInput.value) {
await stopStreamingTranscription(false, activeTranscriptionProvider.value)
}
else {
await resetSttTestVoiceInputSession()
}
}
catch (err) {
console.error('Error stopping STT test:', err)
}
// Finalize transcription if we have streaming text
if (testStreamingText.value.trim() && !testTranscriptionText.value) {
testTranscriptionText.value = testStreamingText.value.trim()
}
// Stop the stream if we started it for testing (and monitoring is not active)
if (testStreamWasStarted.value && !isMonitoring.value) {
try {
stopStream()
testStreamWasStarted.value = false
}
catch (err) {
console.error('Error stopping test stream:', err)
}
}
}
watch(selectedAudioInput, async () => isMonitoring.value && await setupAudioMonitoring())
function handleStreamStartError() {
testTranscriptionError.value = 'Failed to start audio stream. Please check microphone permissions.'
testStatusMessage.value = 'Error: Failed to start audio stream'
isTranscribing.value = false
isTestingSTT.value = false
testStreamWasStarted.value = false
}
watch(activeTranscriptionProvider, async (provider) => {
if (!provider)
return
await hearingStore.loadModelsForProvider(provider)
syncOpenAICompatibleSettings()
// Auto-select first model for Web Speech API if no model is selected
if (provider === 'browser-web-speech-api' && !activeTranscriptionModel.value) {
const models = providerModels.value
if (models.length > 0) {
activeTranscriptionModel.value = models[0].id
console.info('Auto-selected Web Speech API model:', models[0].id)
}
}
const models = providerModels.value
if (models.length > 0 && !models.some(model => model.id === activeTranscriptionModel.value))
activeTranscriptionModel.value = models[0].id
if (shouldRestartMonitoring)
isMonitoring.value = await setupAudioMonitoring()
}, { immediate: true })
onMounted(async () => {
@@ -545,19 +328,7 @@ onMounted(async () => {
})
onUnmounted(() => {
stopSTTTest()
stopAudioMonitoring()
disposeVAD()
// Clean up any active transcription sessions when leaving the page
// This prevents stale sessions from interfering with other pages
if (shouldUseStreamInput.value) {
stopStreamingTranscription(true, activeTranscriptionProvider.value).catch((err) => {
console.warn('[Hearing Module] Error cleaning up transcription session on unmount:', err)
})
}
audioCleanups.value.forEach(cleanup => cleanup())
void stopAudioMonitoring().catch(cause => console.warn('[Hearing Module] Failed to stop playground monitoring:', cause))
})
</script>
@@ -709,7 +480,7 @@ onUnmounted(() => {
<RadioCardManySelect
v-model="activeTranscriptionModel"
v-model:search-query="transcriptionModelSearchQuery"
:items="providerModels.sort((a, b) => a.id === activeTranscriptionModel ? -1 : b.id === activeTranscriptionModel ? 1 : 0)"
:items="sortedProviderModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
@@ -787,29 +558,42 @@ onUnmounted(() => {
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<!-- Audio Monitoring Section -->
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<h2 :class="['mb-4', 'text-lg text-neutral-500 md:text-2xl dark:text-neutral-400']" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
{{ t('settings.pages.modules.hearing.sections.section.playground.title') }}
</div>
</div>
</h2>
<ErrorContainer v-if="error" title="Error occurred" :error="error" mb-4 />
<p :class="['mb-4', 'text-sm text-neutral-400 dark:text-neutral-500']">
{{ t('settings.pages.modules.hearing.sections.section.playground.description') }}
</p>
<Button class="mb-4" w-full @click="toggleMonitoring">
{{ isMonitoring ? 'Stop Monitoring' : 'Start Monitoring' }}
<ErrorContainer
v-if="error || transcriptionPipelineError"
:title="t('settings.pages.modules.hearing.sections.section.playground.error-title')"
:error="error || transcriptionPipelineError"
class="mb-4"
/>
<Button
:class="['mb-4', 'w-full']"
data-testid="hearing-playground-monitor-toggle"
:disabled="!activeTranscriptionProvider || !selectedAudioInput"
@click="toggleMonitoring"
>
{{ isMonitoring
? t('settings.pages.modules.hearing.sections.section.playground.stop')
: t('settings.pages.modules.hearing.sections.section.playground.start') }}
</Button>
<div>
<div v-for="(transcription, index) in transcriptions" :key="index" class="mb-2">
<audio v-if="audioURLs[index]" :src="audioURLs[index]" controls class="w-full" />
<div v-if="transcription" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{{ transcription }}
</div>
</div>
</div>
<HearingPlaygroundTranscripts
:current="currentTranscription"
:is-monitoring="isMonitoring"
:segments="playgroundSegments"
/>
<div flex="~ col gap-4">
<div class="space-y-4">
@@ -854,7 +638,7 @@ onUnmounted(() => {
<div v-else class="space-y-3">
<FieldRange
v-model="useVADThreshold"
v-model="useVolumeThreshold"
label="Sensitivity"
description="Adjust the threshold for speech detection"
:min="1"
@@ -928,111 +712,6 @@ onUnmounted(() => {
</div>
</div>
</div>
<!-- Speech-to-Text Test Section -->
<div w-full rounded-xl bg="neutral-50 dark:[rgba(0,0,0,0.3)]" p-4 flex="~ col gap-4">
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Speech-to-Text Test
</h2>
<div text="sm neutral-400 dark:neutral-500" mb-2>
Test your transcription provider with the selected audio device. This will help verify that STT is working correctly.
</div>
<div v-if="!activeTranscriptionProvider" class="border border-amber-200 rounded-lg bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<div i-solar:warning-circle-line-duotone class="text-lg" />
<span class="text-sm font-medium">Please select a transcription provider above to test</span>
</div>
</div>
<div v-else-if="!selectedAudioInput" class="border border-amber-200 rounded-lg bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<div i-solar:warning-circle-line-duotone class="text-lg" />
<span class="text-sm font-medium">Please select an audio input device to test</span>
</div>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button
:disabled="isTranscribing && !isTestingSTT"
class="flex-1"
@click="isTestingSTT ? stopSTTTest() : startSTTTest()"
>
<div v-if="isTranscribing" class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-lg />
</div>
<div v-else-if="isTestingSTT" class="mr-2">
<div i-solar:stop-circle-line-duotone text-lg />
</div>
<div v-else class="mr-2">
<div i-solar:microphone-line-duotone text-lg />
</div>
{{ isTestingSTT ? 'Stop Test' : isTranscribing ? 'Transcribing...' : 'Start Speech-to-Text Test' }}
</Button>
</div>
<ErrorContainer v-if="testTranscriptionError" title="Transcription Error" :error="testTranscriptionError" />
<div v-if="testStatusMessage" class="border border-primary-200 rounded-lg bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20">
<div class="flex items-center gap-2 text-primary-700 dark:text-primary-400">
<div v-if="isTranscribing" class="animate-spin text-sm" i-solar:spinner-line-duotone />
<div v-else class="text-sm" i-solar:info-circle-line-duotone />
<span class="text-sm font-medium">{{ testStatusMessage }}</span>
</div>
</div>
<div v-if="shouldUseStreamInput" class="border border-blue-200 rounded-lg bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-400">
<div i-solar:info-circle-line-duotone class="text-sm" />
<span class="text-xs">Streaming mode: Transcription will appear in real-time as you speak</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm text-neutral-700 font-medium dark:text-neutral-300">
Transcription Result
</label>
<div
v-if="testTranscriptionText || testStreamingText"
class="min-h-[100px] border border-neutral-200 rounded-lg bg-white p-3 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<div v-if="testStreamingText && shouldUseStreamInput" class="text-neutral-600 dark:text-neutral-400">
<div class="mb-2 font-medium">
Current transcription (streaming):
</div>
<div class="whitespace-pre-wrap">
{{ testStreamingText }}
</div>
</div>
<div v-if="testTranscriptionText" class="text-neutral-700 dark:text-neutral-200">
<div v-if="testStreamingText && shouldUseStreamInput" class="mb-2 mt-3 border-t border-neutral-200 pt-2 font-medium dark:border-neutral-700">
Final transcription:
</div>
<div class="whitespace-pre-wrap">
{{ testTranscriptionText }}
</div>
</div>
</div>
<div
v-else
class="min-h-[100px] border border-neutral-300 rounded-lg border-dashed bg-neutral-50 p-3 text-sm text-neutral-400 dark:border-neutral-700 dark:bg-neutral-900/50 dark:text-neutral-500"
>
No transcription yet. Click "Start Speech-to-Text Test" and speak into your microphone.
</div>
</div>
<div v-if="activeTranscriptionProvider" class="text-xs text-neutral-500 dark:text-neutral-400">
<div>Provider: <span class="font-medium">{{ configuredTranscriptionProvidersMetadata.find(p => p.id === activeTranscriptionProvider)?.localizedName || activeTranscriptionProvider }}</span></div>
<div v-if="activeTranscriptionModel">
Model: <span class="font-medium">{{ activeTranscriptionModel }}</span>
</div>
<div>Mode: <span class="font-medium">{{ shouldUseStreamInput ? 'Streaming (real-time)' : 'Recording (file-based)' }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -9,6 +9,7 @@ export * from './use-async-state'
export * from './use-breakpoints'
export * from './use-build-info'
export * from './use-chat-session/summary'
export * from './use-hearing-playground-segments'
export * from './use-inference-preload'
export * from './use-inference-status'
export * from './use-lamp-flicker-animation'
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { useHearingPlaygroundSegments } from './use-hearing-playground-segments'
describe('hearing playground segments', () => {
it('keeps empty audio and the next transcript in separate segments', () => {
const playground = useHearingPlaygroundSegments()
const emptyRecording = new Blob(['noise'], { type: 'audio/wav' })
const speechRecording = new Blob(['speech'], { type: 'audio/wav' })
const emptyMetadata = playground.startRecording(emptyRecording)
playground.finishEmpty(emptyMetadata)
const speechMetadata = playground.startRecording(speechRecording)
playground.finishRecording(speechMetadata, 'Second sentence.')
expect(playground.segments.value).toHaveLength(2)
expect(playground.segments.value[0]).toMatchObject({
recording: emptyRecording,
status: 'empty',
text: '',
})
expect(playground.segments.value[1]).toMatchObject({
recording: speechRecording,
status: 'complete',
text: 'Second sentence.',
})
})
it('updates each segment by identity when results finish out of order', () => {
const playground = useHearingPlaygroundSegments()
const firstMetadata = playground.startRecording(new Blob(['first']))
const secondMetadata = playground.startRecording(new Blob(['second']))
playground.finishRecording(secondMetadata, 'Second sentence.')
playground.finishRecording(firstMetadata, 'First sentence.')
expect(playground.segments.value.map(segment => segment.text)).toEqual([
'First sentence.',
'Second sentence.',
])
})
it('replaces a volatile streaming transcript when the provider corrects it', () => {
// ROOT CAUSE:
//
// Apple Speech sends complete volatile snapshots that can revise earlier characters.
// Appending each snapshot kept both the incorrect text and its correction.
//
// Before: "今天天气很号 今天天气很好"
// After: "今天天气很好"
const playground = useHearingPlaygroundSegments()
playground.replaceStreamingText('今天天气很号')
playground.replaceStreamingText('今天天气很好')
expect(playground.current.value).toBe('今天天气很好')
})
it('clears streaming and completed transcripts when the playground resets', () => {
const playground = useHearingPlaygroundSegments()
const metadata = playground.startRecording(new Blob(['speech']))
playground.finishRecording(metadata, 'First provider result.')
playground.replaceStreamingText('Second provider partial result')
playground.clear()
expect(playground.current.value).toBe('')
expect(playground.segments.value).toEqual([])
})
})
@@ -0,0 +1,106 @@
import { readonly, ref, shallowRef } from 'vue'
/** Lifecycle state of one recorded Hearing playground segment. */
export type HearingPlaygroundSegmentStatus = 'transcribing' | 'complete' | 'empty' | 'error'
/** A recorded playground utterance and the transcription state that belongs to it. */
export interface HearingPlaygroundSegment {
/** Stable identity used to correlate asynchronous provider results. */
id: number
/** Audio sent to the provider. VAD-triggered recordings include retained speech padding. */
recording?: Blob
/** Final provider text. Empty while the request is pending or produced no text. */
text: string
/** User-facing failure details when transcription throws. */
error?: string
/** Current transcription state for this segment. */
status: HearingPlaygroundSegmentStatus
}
interface HearingPlaygroundSegmentMetadata extends Record<string, unknown> {
playgroundSegmentId: number
}
function segmentIdFrom(metadata: Record<string, unknown> | undefined): number | undefined {
const id = metadata?.playgroundSegmentId
return typeof id === 'number' ? id : undefined
}
/**
* Keeps playground recordings and asynchronous transcription results correlated.
* Empty and failed results remain visible so later text cannot shift onto earlier audio.
*/
export function useHearingPlaygroundSegments() {
const current = shallowRef('')
const segments = ref<HearingPlaygroundSegment[]>([])
let nextSegmentId = 0
function updateSegment(
metadata: Record<string, unknown> | undefined,
update: (segment: HearingPlaygroundSegment) => HearingPlaygroundSegment,
) {
const segmentId = segmentIdFrom(metadata)
if (segmentId === undefined)
return
segments.value = segments.value.map(segment => segment.id === segmentId ? update(segment) : segment)
}
function startRecording(recording: Blob): HearingPlaygroundSegmentMetadata {
const id = ++nextSegmentId
segments.value = [
...segments.value,
{ id, recording, text: '', status: 'transcribing' },
]
return { playgroundSegmentId: id }
}
function finishRecording(metadata: Record<string, unknown> | undefined, text: string) {
const finalText = text.trim()
if (!finalText) {
finishEmpty(metadata)
return
}
updateSegment(metadata, segment => ({ ...segment, text: finalText, status: 'complete' }))
}
function finishEmpty(metadata: Record<string, unknown> | undefined) {
updateSegment(metadata, segment => ({ ...segment, text: '', status: 'empty' }))
}
function finishError(metadata: Record<string, unknown> | undefined, error: string) {
updateSegment(metadata, segment => ({ ...segment, error, status: 'error' }))
}
function replaceStreamingText(text: string) {
current.value = text.trim()
}
function finishStreaming(text: string) {
const finalText = text.trim() || current.value.trim()
current.value = ''
if (!finalText)
return
const id = ++nextSegmentId
segments.value = [...segments.value, { id, text: finalText, status: 'complete' }]
}
function clear() {
current.value = ''
segments.value = []
}
return {
current: readonly(current),
segments: readonly(segments),
startRecording,
finishRecording,
finishEmpty,
finishError,
replaceStreamingText,
finishStreaming,
clear,
}
}