refactor(stage-*): improve transcription UX and DX (#1685)
This commit is contained in:
@@ -62,8 +62,11 @@
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@types/audioworklet": "catalog:",
|
||||
"@types/three": "^0.184.0",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"@webgpu/types": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"unplugin-info": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
|
||||
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
@@ -26,6 +27,7 @@ import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
|
||||
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
|
||||
import { useTranscriptions } from '../../composables/use-transcriptions'
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
@@ -68,6 +70,14 @@ function isMobileDevice() {
|
||||
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const { isListening } = useTranscriptions(
|
||||
{
|
||||
messageInputRef: messageInput,
|
||||
sendMessage: handleSend,
|
||||
isStageTamagotchi,
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isMobileDevice()) {
|
||||
await handleSend()
|
||||
@@ -123,7 +133,7 @@ async function setupAnalyzer() {
|
||||
analyzerSource.connect(analyser)
|
||||
}
|
||||
|
||||
watch([hearingDialogOpen, enabled, stream], () => {
|
||||
watch([enabled, stream], () => {
|
||||
setupAnalyzer()
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -200,7 +210,7 @@ onMounted(() => {
|
||||
title="Hearing"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<IndicatorMicVolume v-if="enabled" size-5 color-class="text-neutral-500 dark:text-neutral-400" />
|
||||
<IndicatorMicVolume v-if="enabled" size-5 :color-class="isListening ? undefined : 'text-neutral-500 dark:text-neutral-400'" />
|
||||
<div v-else i-solar:microphone-3-outline size-5 text="neutral-500 dark:neutral-400" />
|
||||
</Transition>
|
||||
</button>
|
||||
|
||||
@@ -4,28 +4,29 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { BasicTextarea, FieldCombobox } from '@proj-airi/ui'
|
||||
import { until, useLocalStorage } from '@vueuse/core'
|
||||
import { BasicTextarea } from '@proj-airi/ui'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, PopoverContent, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IndicatorMicVolume from './IndicatorMicVolume.vue'
|
||||
|
||||
const messageInput = ref('')
|
||||
import { useTranscriptions } from '../../composables/use-transcriptions'
|
||||
|
||||
const messageInput = ref<string>('')
|
||||
const hearingPopoverOpen = ref(false)
|
||||
const sessionsDrawerOpen = ref(false)
|
||||
const isComposing = ref(false)
|
||||
const isListening = ref(false) // Transcription listening state (separate from microphone enabled)
|
||||
const DOUBLE_ENTER_INTERVAL_MS = 300
|
||||
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
|
||||
const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const
|
||||
@@ -37,8 +38,8 @@ const providersStore = useProvidersStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
||||
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
|
||||
const { askPermission, startStream } = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
|
||||
const { askPermission } = useSettingsAudioDevice()
|
||||
const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
||||
@@ -51,73 +52,13 @@ const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
||||
'double-enter': t('stage.send-mode.double-enter'),
|
||||
}))
|
||||
|
||||
// Transcription pipeline
|
||||
const hearingStore = useHearingStore()
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const { configured: hearingConfigured, autoSendEnabled, autoSendDelay } = storeToRefs(hearingStore)
|
||||
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
|
||||
|
||||
// Auto-send logic
|
||||
let autoSendTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const pendingAutoSendText = ref('')
|
||||
|
||||
function clearPendingAutoSend() {
|
||||
if (autoSendTimeout) {
|
||||
clearTimeout(autoSendTimeout)
|
||||
autoSendTimeout = undefined
|
||||
}
|
||||
pendingAutoSendText.value = ''
|
||||
}
|
||||
|
||||
async function debouncedAutoSend(text: string) {
|
||||
// Double-check auto-send is enabled before proceeding
|
||||
if (!autoSendEnabled.value) {
|
||||
clearPendingAutoSend()
|
||||
return
|
||||
}
|
||||
|
||||
// Add text to pending buffer
|
||||
pendingAutoSendText.value = pendingAutoSendText.value ? `${pendingAutoSendText.value} ${text}` : text
|
||||
|
||||
// Clear existing timeout
|
||||
if (autoSendTimeout) {
|
||||
clearTimeout(autoSendTimeout)
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
autoSendTimeout = setTimeout(async () => {
|
||||
// Final check before sending - auto-send might have been disabled while waiting
|
||||
if (!autoSendEnabled.value) {
|
||||
clearPendingAutoSend()
|
||||
return
|
||||
}
|
||||
|
||||
const textToSend = pendingAutoSendText.value.trim()
|
||||
if (textToSend && autoSendEnabled.value) {
|
||||
try {
|
||||
// `ingest()` resolves only after the full assistant turn finishes; clear UI/buffer now so
|
||||
// the next SentenceEnd during streaming does not append to the message we already committed.
|
||||
messageInput.value = ''
|
||||
pendingAutoSendText.value = ''
|
||||
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
|
||||
await ingest(textToSend, {
|
||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
||||
model: activeModel.value,
|
||||
providerConfig,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ChatArea] Auto-send error:', err)
|
||||
// Preserve any transcription that arrived while ingest was in flight (see PR review).
|
||||
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ')
|
||||
pendingAutoSendText.value = [textToSend, pendingAutoSendText.value.trim()].filter(Boolean).join(' ')
|
||||
}
|
||||
}
|
||||
autoSendTimeout = undefined
|
||||
}, autoSendDelay.value)
|
||||
}
|
||||
const { isListening, startStreamingTranscription, stopStreamingTranscription, autoSendEnabled } = useTranscriptions(
|
||||
{
|
||||
messageInputRef: messageInput,
|
||||
sendMessage: handleSend,
|
||||
isStageTamagotchi,
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSend() {
|
||||
if (!messageInput.value.trim() || isComposing.value) {
|
||||
@@ -137,7 +78,8 @@ async function handleSend() {
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
messageInput.value = textToSend
|
||||
// preserve any user input when failed to send the message
|
||||
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ')
|
||||
chatSession.setSessionMessages(chatSession.activeSessionId, [
|
||||
...messages.value.slice(0, -1),
|
||||
{
|
||||
@@ -223,231 +165,12 @@ async function setupAnalyzer() {
|
||||
analyzerSource.connect(analyser)
|
||||
}
|
||||
|
||||
watch([hearingPopoverOpen, enabled, stream], () => {
|
||||
watch([enabled], () => {
|
||||
setupAnalyzer()
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
teardownAnalyzer()
|
||||
stopListening()
|
||||
|
||||
// Clear auto-send timeout on unmount
|
||||
if (autoSendTimeout) {
|
||||
clearTimeout(autoSendTimeout)
|
||||
autoSendTimeout = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Transcription listening functions
|
||||
async function startListening() {
|
||||
// Allow calling this even if already listening - transcribeForMediaStream will handle session reuse/restart
|
||||
try {
|
||||
console.info('[ChatArea] Starting listening...', {
|
||||
enabled: enabled.value,
|
||||
hasStream: !!stream.value,
|
||||
supportsStreamInput: supportsStreamInput.value,
|
||||
hearingConfigured: hearingConfigured.value,
|
||||
})
|
||||
|
||||
// Auto-configure Web Speech API as default if no provider is configured
|
||||
if (!hearingConfigured.value) {
|
||||
// Check if Web Speech API is available in the browser
|
||||
// Web Speech API is NOT available in Electron (stage-tamagotchi) - it requires Google's embedded API keys
|
||||
// which are not available in Electron, causing it to fail at runtime
|
||||
const isWebSpeechAvailable = typeof window !== 'undefined'
|
||||
&& !isStageTamagotchi() // Explicitly exclude Electron
|
||||
&& ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)
|
||||
|
||||
if (isWebSpeechAvailable) {
|
||||
console.info('[ChatArea] No transcription provider configured. Auto-configuring Web Speech API as default...')
|
||||
|
||||
// Initialize the provider in the providers store first
|
||||
try {
|
||||
providersStore.initializeProvider('browser-web-speech-api')
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[ChatArea] Error initializing Web Speech API provider:', err)
|
||||
}
|
||||
|
||||
// Set as active provider
|
||||
hearingStore.activeTranscriptionProvider = 'browser-web-speech-api'
|
||||
|
||||
// Wait for reactivity to update
|
||||
await nextTick()
|
||||
|
||||
// Verify the provider was set correctly
|
||||
if (hearingStore.activeTranscriptionProvider === 'browser-web-speech-api') {
|
||||
console.info('[ChatArea] Web Speech API configured as default provider')
|
||||
// Continue with transcription - Web Speech API is ready
|
||||
}
|
||||
else {
|
||||
console.error('[ChatArea] Failed to set Web Speech API as default provider')
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error('[ChatArea] Web Speech API not available. No transcription provider configured and Web Speech API is not available in this browser. Please go to Settings > Modules > Hearing to configure a transcription provider. Browser support:', {
|
||||
hasWindow: typeof window !== 'undefined',
|
||||
hasWebkitSpeechRecognition: typeof window !== 'undefined' && 'webkitSpeechRecognition' in window,
|
||||
hasSpeechRecognition: typeof window !== 'undefined' && 'SpeechRecognition' in window,
|
||||
})
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Request microphone permission if needed (microphone should already be enabled by the user)
|
||||
if (!stream.value) {
|
||||
console.info('[ChatArea] Requesting microphone permission...')
|
||||
await askPermission()
|
||||
|
||||
// If still no stream, try starting it manually
|
||||
if (!stream.value && enabled.value) {
|
||||
console.info('[ChatArea] Attempting to start stream manually...')
|
||||
startStream()
|
||||
// Wait for the stream to become available with a timeout.
|
||||
try {
|
||||
await until(stream).toBeTruthy({ timeout: 3000, throwOnTimeout: true })
|
||||
}
|
||||
catch {
|
||||
console.error('[ChatArea] Timed out waiting for audio stream.')
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stream.value) {
|
||||
const errorMsg = 'Failed to get audio stream for transcription. Please check microphone permissions and ensure a device is selected.'
|
||||
console.error('[ChatArea]', errorMsg)
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if streaming input is supported
|
||||
if (!shouldUseStreamInput.value) {
|
||||
const errorMsg = 'Streaming input not supported by the selected transcription provider. Please select a provider that supports streaming (e.g., Web Speech API).'
|
||||
console.warn('[ChatArea]', errorMsg)
|
||||
// Clean up any existing sessions from other pages (e.g., test page) that might interfere
|
||||
await stopStreamingTranscription(true)
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[ChatArea] Starting streaming transcription with stream:', stream.value.id)
|
||||
|
||||
// Call transcribeForMediaStream - it's async so we await it
|
||||
// Set listening state AFTER successful call
|
||||
try {
|
||||
await transcribeForMediaStream(stream.value, {
|
||||
onSentenceEnd: (delta) => {
|
||||
if (delta && delta.trim()) {
|
||||
// Append transcribed text to message input
|
||||
const currentText = messageInput.value.trim()
|
||||
messageInput.value = currentText ? `${currentText} ${delta}` : delta
|
||||
console.info('[ChatArea] Received transcription delta:', delta)
|
||||
|
||||
// Auto-send if enabled - check the current value (not captured in closure)
|
||||
// This ensures we always respect the current setting, even if callbacks are reused
|
||||
if (autoSendEnabled.value) {
|
||||
debouncedAutoSend(delta)
|
||||
}
|
||||
else {
|
||||
// If auto-send is disabled, clear any pending auto-send text to prevent accidental sends
|
||||
clearPendingAutoSend()
|
||||
}
|
||||
}
|
||||
},
|
||||
// Omit onSpeechEnd to avoid re-adding user-deleted text; use sentence deltas only.
|
||||
})
|
||||
|
||||
// Only set listening to true if transcription started successfully
|
||||
// (transcribeForMediaStream might return early if session already exists)
|
||||
isListening.value = true
|
||||
console.info('[ChatArea] Streaming transcription initiated successfully')
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ChatArea] Transcription error:', err)
|
||||
isListening.value = false
|
||||
throw err // Re-throw to be caught by outer catch
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ChatArea] Failed to start transcription:', err)
|
||||
isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopListening() {
|
||||
if (!isListening.value)
|
||||
return
|
||||
|
||||
try {
|
||||
console.info('[ChatArea] Stopping transcription...')
|
||||
|
||||
// Clear auto-send timeout
|
||||
clearPendingAutoSend()
|
||||
|
||||
// Send any pending text immediately if auto-send is enabled
|
||||
if (autoSendEnabled.value && pendingAutoSendText.value.trim()) {
|
||||
const textToSend = pendingAutoSendText.value.trim()
|
||||
pendingAutoSendText.value = ''
|
||||
try {
|
||||
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
|
||||
await ingest(textToSend, {
|
||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
||||
model: activeModel.value,
|
||||
providerConfig,
|
||||
})
|
||||
messageInput.value = ''
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ChatArea] Auto-send error on stop:', err)
|
||||
}
|
||||
}
|
||||
|
||||
await stopStreamingTranscription(true)
|
||||
isListening.value = false
|
||||
console.info('[ChatArea] Transcription stopped')
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ChatArea] Error stopping transcription:', err)
|
||||
isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Start listening when microphone is enabled and stream is available
|
||||
watch(enabled, async (val) => {
|
||||
if (val && stream.value) {
|
||||
// Microphone was just enabled and we have a stream, start transcription
|
||||
await startListening()
|
||||
}
|
||||
else if (!val && isListening.value) {
|
||||
// Microphone was disabled, stop transcription
|
||||
await stopListening()
|
||||
}
|
||||
})
|
||||
|
||||
// Start listening when stream becomes available (if microphone is enabled)
|
||||
watch(stream, async (val) => {
|
||||
if (val && enabled.value && !isListening.value) {
|
||||
// Stream became available and microphone is enabled, start transcription
|
||||
await startListening()
|
||||
}
|
||||
else if (!val && isListening.value) {
|
||||
// Stream was lost, stop transcription
|
||||
await stopListening()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for auto-send setting changes and clear pending sends if disabled
|
||||
watch(autoSendEnabled, (enabled) => {
|
||||
if (!enabled) {
|
||||
// Auto-send was disabled - clear any pending auto-send
|
||||
clearPendingAutoSend()
|
||||
console.info('[ChatArea] Auto-send disabled, cleared pending text')
|
||||
}
|
||||
})
|
||||
|
||||
watch(sendMode, () => {
|
||||
@@ -553,7 +276,7 @@ watch(sendMode, () => {
|
||||
:title="t('settings.hearing.title')"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<IndicatorMicVolume v-if="enabled" class="h-5 w-5" />
|
||||
<IndicatorMicVolume v-if="enabled" class="h-5 w-5" :color-class="isListening ? undefined : 'text-neutral-500 dark:text-neutral-400'" />
|
||||
<div v-else class="i-ph:microphone-slash h-5 w-5" />
|
||||
</Transition>
|
||||
</button>
|
||||
@@ -567,45 +290,12 @@ watch(sendMode, () => {
|
||||
'flex flex-col gap-3',
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="relative h-28 w-28 select-none">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-20 w-20 rounded-full transition-all duration-150 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1 + normalizedVolume * 0.35})`, opacity: String(0.25 + normalizedVolume * 0.25) }"
|
||||
:class="enabled ? 'bg-primary-500/15 dark:bg-primary-600/20' : 'bg-neutral-300/20 dark:bg-neutral-700/20'"
|
||||
/>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-24 w-24 rounded-full transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + normalizedVolume * 0.55})`, opacity: String(0.15 + normalizedVolume * 0.2) }"
|
||||
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
|
||||
/>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-28 w-28 rounded-full transition-all duration-300 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + normalizedVolume * 0.8})`, opacity: String(0.08 + normalizedVolume * 0.15) }"
|
||||
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
|
||||
/>
|
||||
<button
|
||||
class="absolute left-1/2 top-1/2 grid h-16 w-16 place-items-center rounded-full shadow-md outline-none transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
|
||||
:class="enabled
|
||||
? 'bg-primary-500 text-white hover:bg-primary-600 active:scale-95'
|
||||
: 'bg-neutral-200 text-neutral-600 hover:bg-neutral-300 active:scale-95 dark:bg-neutral-700 dark:text-neutral-200'"
|
||||
@click="enabled = !enabled"
|
||||
>
|
||||
<div :class="enabled ? 'i-ph:microphone' : 'i-ph:microphone-slash'" class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ enabled ? 'Microphone enabled' : 'Microphone disabled' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FieldCombobox
|
||||
v-model="selectedAudioInput"
|
||||
label="Input device"
|
||||
description="Select the microphone you want to use."
|
||||
:options="audioInputs.map(device => ({ label: device.label || 'Unknown Device', value: device.deviceId }))"
|
||||
layout="vertical"
|
||||
placeholder="Select microphone"
|
||||
<HearingConfig
|
||||
v-model:auto-send="autoSendEnabled"
|
||||
:transcription="isListening"
|
||||
:granted="true"
|
||||
:volume-level="normalizedVolume"
|
||||
@toggle-transcription="() => isListening ? stopStreamingTranscription() : startStreamingTranscription()"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { until } from '@vueuse/core'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useTranscriptions } from './use-transcriptions'
|
||||
|
||||
function createMockStore() {
|
||||
return {
|
||||
activeTranscriptionProvider: undefined,
|
||||
configured: ref(false),
|
||||
autoSendEnabled: ref(true),
|
||||
autoSendDelay: ref(2000),
|
||||
initializeProvider: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
const mockTranscribedContent = 'test content'
|
||||
function createMockPipeline() {
|
||||
return {
|
||||
transcribeForMediaStream: vi.fn().mockImplementation((_stream, options: { onSentenceEnd: (delta: string) => void }) => {
|
||||
options.onSentenceEnd(mockTranscribedContent)
|
||||
}),
|
||||
stopStreamingTranscription: vi.fn().mockResolvedValue(undefined),
|
||||
supportsStreamInput: ref(true),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockAudioDevice() {
|
||||
const instance = {
|
||||
enabled: ref(false),
|
||||
stream: ref(null),
|
||||
askPermission: vi.fn().mockResolvedValue(undefined),
|
||||
startStream: vi.fn(),
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
let mockHearingStore: ReturnType<typeof createMockStore>
|
||||
let mockHearingPipeline: ReturnType<typeof createMockPipeline>
|
||||
let mockAudioDevice: ReturnType<typeof createMockAudioDevice>
|
||||
let mockProvidersStore: ReturnType<typeof createMockStore>
|
||||
|
||||
// Mock the modules
|
||||
vi.mock('@proj-airi/stage-ui/stores/modules/hearing', () => ({
|
||||
useHearingStore: vi.fn().mockImplementation(() => mockHearingStore),
|
||||
useHearingSpeechInputPipeline: vi.fn().mockImplementation(() => mockHearingPipeline),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/providers', () => ({
|
||||
useProvidersStore: vi.fn().mockImplementation(() => mockProvidersStore),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/settings', () => ({
|
||||
useSettingsAudioDevice: vi.fn().mockImplementation(() => mockAudioDevice),
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: vi.fn().mockImplementation((val: any) => val),
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
until: vi.fn(),
|
||||
}))
|
||||
|
||||
// Global setup for jsdom environment
|
||||
beforeAll(() => {
|
||||
// Ensure window is available
|
||||
if (typeof window === 'undefined') {
|
||||
;(globalThis as any).window = {
|
||||
webkitSpeechRecognition: undefined,
|
||||
SpeechRecognition: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useTranscriptions', () => {
|
||||
// Setup mutable instances before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers() // Use fake timers for auto-send tests
|
||||
mockHearingStore = createMockStore()
|
||||
mockHearingPipeline = createMockPipeline()
|
||||
mockAudioDevice = createMockAudioDevice()
|
||||
mockProvidersStore = createMockStore()
|
||||
|
||||
// Mock 'until' to resolve immediately for stream checks
|
||||
;(until as any).mockImplementation((_source: Ref) => ({
|
||||
toBeTruthy: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Mock SpeechRecognition for browser tests
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).SpeechRecognition = function () {
|
||||
this.start = vi.fn()
|
||||
this.stop = vi.fn()
|
||||
this.onresult = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createOptions = (isTamagotchi = false) => ({
|
||||
messageInputRef: ref(''),
|
||||
sendMessage: vi.fn(),
|
||||
isStageTamagotchi: ref(isTamagotchi),
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with isListening false', () => {
|
||||
const { isListening } = useTranscriptions(createOptions())
|
||||
|
||||
expect(isListening.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose startListening and stopListening', () => {
|
||||
const { startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
expect(startStreamingTranscription).toBeInstanceOf(Function)
|
||||
expect(stopStreamingTranscription).toBeInstanceOf(Function)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-Configuration (Web Speech API)', () => {
|
||||
it('should auto-configure Web Speech API if no provider is set', async () => {
|
||||
mockHearingStore.configured.value = false
|
||||
mockAudioDevice.enabled.value = true
|
||||
|
||||
const { startStreamingTranscription }
|
||||
= useTranscriptions(createOptions())
|
||||
await startStreamingTranscription()
|
||||
|
||||
expect(mockProvidersStore.initializeProvider).toHaveBeenCalledWith('browser-web-speech-api')
|
||||
expect(mockHearingStore.activeTranscriptionProvider).toBe('browser-web-speech-api')
|
||||
})
|
||||
|
||||
it('should fail gracefully if Web Speech API is not available', async () => {
|
||||
// Setup: Tamagotchi mode or no API
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any).SpeechRecognition
|
||||
delete (window as any).webkitSpeechRecognition
|
||||
}
|
||||
|
||||
mockHearingStore.configured.value = false
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
|
||||
const { isListening, startStreamingTranscription }
|
||||
= useTranscriptions(createOptions())
|
||||
await startStreamingTranscription()
|
||||
|
||||
expect(isListening.value).toBe(false)
|
||||
expect(mockHearingPipeline.transcribeForMediaStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle tamagotchi', async () => {
|
||||
mockHearingStore.configured.value = false
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
|
||||
const { isListening, stopStreamingTranscription }
|
||||
= useTranscriptions(createOptions(true))
|
||||
await stopStreamingTranscription()
|
||||
expect(isListening.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming Logic', () => {
|
||||
it('should start streaming if stream exists and provider supports it', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { isListening, startStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
await startStreamingTranscription()
|
||||
|
||||
await nextTick()
|
||||
expect(isListening.value).toBe(true)
|
||||
expect(mockHearingPipeline.transcribeForMediaStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'stream-1' }),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should request permission if stream is missing', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = null
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { startStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
expect(mockAudioDevice.askPermission).toHaveBeenCalled()
|
||||
expect(mockAudioDevice.startStream).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop streaming if stream is missing after permission', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = null
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true;
|
||||
|
||||
// simulate failure (stream never appears)
|
||||
(until as any).mockImplementation(() => ({
|
||||
toBeTruthy: vi.fn().mockRejectedValue(new Error('Timeout')),
|
||||
}))
|
||||
|
||||
const { isListening, startStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
expect(isListening.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcription & Input', () => {
|
||||
it('should append transcribed text to messageInputRef', async () => {
|
||||
const mockInput = ref('')
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { startStreamingTranscription }
|
||||
= useTranscriptions({ ...createOptions(), messageInputRef: mockInput })
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
expect(mockInput.value).toBe(mockTranscribedContent)
|
||||
})
|
||||
|
||||
it('should append transcribed text with a space when input contains value', async () => {
|
||||
const prependText = 'prepend text'
|
||||
const mockInput = ref(prependText)
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { startStreamingTranscription }
|
||||
= useTranscriptions({ ...createOptions(), messageInputRef: mockInput })
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
expect(mockInput.value).toBe(`${prependText} ${mockTranscribedContent}`)
|
||||
})
|
||||
|
||||
it('should trigger auto-send after delay', async () => {
|
||||
const mockInput = ref('')
|
||||
const mockSendMessage = vi.fn()
|
||||
|
||||
mockHearingStore.autoSendDelay.value = 500
|
||||
mockHearingStore.configured.value = true
|
||||
mockHearingStore.autoSendEnabled.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { startStreamingTranscription }
|
||||
= useTranscriptions({ ...createOptions(), messageInputRef: mockInput, sendMessage: mockSendMessage })
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear pending auto-send if disabled', async () => {
|
||||
const mockInput = ref('')
|
||||
const mockSendMessage = vi.fn()
|
||||
|
||||
mockHearingStore.autoSendDelay.value = 500
|
||||
mockHearingStore.configured.value = true
|
||||
mockHearingStore.autoSendEnabled.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { startStreamingTranscription }
|
||||
= useTranscriptions({ ...createOptions(), messageInputRef: mockInput, sendMessage: mockSendMessage })
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
|
||||
// Disable auto-send before timeout
|
||||
mockHearingStore.autoSendEnabled.value = false
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should stop streaming and clear timeout', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { isListening, startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
await startStreamingTranscription()
|
||||
await nextTick()
|
||||
expect(isListening.value).toBe(true)
|
||||
|
||||
await stopStreamingTranscription()
|
||||
await nextTick()
|
||||
expect(isListening.value).toBe(false)
|
||||
expect(mockHearingPipeline.stopStreamingTranscription).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should stop streaming on unmount', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const app = mount({
|
||||
setup() {
|
||||
const { startStreamingTranscription } = useTranscriptions(createOptions())
|
||||
startStreamingTranscription()
|
||||
},
|
||||
template: '<div></div>',
|
||||
})
|
||||
await nextTick()
|
||||
expect(mockHearingPipeline.transcribeForMediaStream).toHaveBeenCalled()
|
||||
|
||||
app.unmount()
|
||||
await nextTick()
|
||||
expect(mockHearingPipeline.stopStreamingTranscription).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactive watchers', () => {
|
||||
it('should stop listening if microphone is disabled', async () => {
|
||||
mockHearingStore.configured.value = true
|
||||
mockAudioDevice.stream.value = { id: 'stream-1' } as any
|
||||
mockAudioDevice.enabled.value = true
|
||||
mockHearingPipeline.supportsStreamInput.value = true
|
||||
|
||||
const { isListening, startStreamingTranscription } = useTranscriptions(createOptions())
|
||||
|
||||
await startStreamingTranscription()
|
||||
|
||||
await nextTick()
|
||||
expect(isListening.value).toBe(true)
|
||||
|
||||
mockAudioDevice.enabled.value = false
|
||||
|
||||
await nextTick()
|
||||
expect(isListening.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { 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 { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { nextTick, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
|
||||
interface TranscriptionOptions {
|
||||
messageInputRef: Ref<string>
|
||||
sendMessage: () => void
|
||||
isStageTamagotchi: MaybeRefOrGetter<boolean>
|
||||
}
|
||||
|
||||
export function useTranscriptions(options: TranscriptionOptions) {
|
||||
const { messageInputRef: messageInput, sendMessage, isStageTamagotchi } = options
|
||||
|
||||
const hearingStore = useHearingStore()
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const { configured: hearingConfigured, autoSendEnabled, autoSendDelay } = storeToRefs(hearingStore)
|
||||
const { enabled: hearingEnabled, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
const providersStore = useProvidersStore()
|
||||
const { askPermission, startStream } = useSettingsAudioDevice()
|
||||
|
||||
const isListening = ref(false)
|
||||
|
||||
// Auto-send logic
|
||||
let autoSendTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
function clearPendingAutoSend() {
|
||||
if (autoSendTimeout) {
|
||||
clearTimeout(autoSendTimeout)
|
||||
autoSendTimeout = undefined
|
||||
}
|
||||
}
|
||||
async function debouncedAutoSend() {
|
||||
// Double-check auto-send is enabled before proceeding
|
||||
if (!autoSendEnabled.value) {
|
||||
clearPendingAutoSend()
|
||||
return
|
||||
}
|
||||
if (autoSendTimeout) {
|
||||
clearTimeout(autoSendTimeout)
|
||||
}
|
||||
|
||||
autoSendTimeout = setTimeout(async () => {
|
||||
// Final check before sending - auto-send might have been disabled while waiting
|
||||
if (!autoSendEnabled.value) {
|
||||
clearPendingAutoSend()
|
||||
return
|
||||
}
|
||||
sendMessage()
|
||||
autoSendTimeout = undefined
|
||||
}, autoSendDelay.value)
|
||||
}
|
||||
|
||||
const stopStreaming = async () => {
|
||||
if (!isListening.value)
|
||||
return
|
||||
|
||||
try {
|
||||
console.info('Stopping transcription...', { source: 'useTranscriptions' })
|
||||
clearPendingAutoSend()
|
||||
await stopStreamingTranscription(true)
|
||||
isListening.value = false
|
||||
console.info('Transcription stopped', { source: 'useTranscriptions' })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Error stopping transcription:', err, { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startStreaming = async () => {
|
||||
console.info('Starting streaming transcription', {
|
||||
enabled: hearingEnabled.value,
|
||||
hasStream: !!stream.value,
|
||||
supportsStreamInput: supportsStreamInput.value,
|
||||
hearingConfigured: hearingConfigured.value,
|
||||
}, { source: 'useTranscriptions' })
|
||||
|
||||
// Auto-configure Web Speech API as default if no provider is configured
|
||||
if (!hearingConfigured.value) {
|
||||
console.info('No transcription provider configured. Auto-configuring Web Speech API as default', { source: 'useTranscriptions' })
|
||||
// Check if Web Speech API is available in the browser
|
||||
// Web Speech API is NOT available in Electron (stage-tamagotchi) - it requires Google's embedded API keys
|
||||
// which are not available in Electron, causing it to fail at runtime
|
||||
const isWebSpeechAvailable = typeof window !== 'undefined'
|
||||
&& !toValue(isStageTamagotchi) // Explicitly exclude Electron
|
||||
&& ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)
|
||||
|
||||
if (!isWebSpeechAvailable) {
|
||||
// TODO: also propagate to user
|
||||
const errorMsg = 'Web Speech API is not available and no transcription provider is configured. Please go to Settings > Modules > Hearing to configure a transcription provider. '
|
||||
console.error(errorMsg, 'Browser support:', {
|
||||
hasWindow: typeof window !== 'undefined',
|
||||
hasWebkitSpeechRecognition: typeof window !== 'undefined' && 'webkitSpeechRecognition' in window,
|
||||
hasSpeechRecognition: typeof window !== 'undefined' && 'SpeechRecognition' in window,
|
||||
}, { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the provider in the providers store first
|
||||
try {
|
||||
providersStore.initializeProvider('browser-web-speech-api')
|
||||
hearingStore.activeTranscriptionProvider = 'browser-web-speech-api'
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('Error initializing Web Speech API provider:', err, { source: 'useTranscriptions' })
|
||||
}
|
||||
// Wait for reactivity to update
|
||||
await nextTick()
|
||||
|
||||
// Verify the provider was set to Web Speech API
|
||||
if (hearingStore.activeTranscriptionProvider !== 'browser-web-speech-api') {
|
||||
console.error('Failed to set Web Speech API as default provider', { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
console.info('Web Speech API configured as default provider', { source: 'useTranscriptions' })
|
||||
}
|
||||
|
||||
// Check if streaming input is supported
|
||||
// TODO: implement non-streaming transcription
|
||||
if (!supportsStreamInput.value) {
|
||||
const errorMsg = 'Streaming input not supported by the selected transcription provider. Please select a provider that supports streaming (e.g., Web Speech API).'
|
||||
console.warn(errorMsg, { source: 'useTranscriptions' })
|
||||
// Clean up any existing sessions from other pages (e.g., test page) that might interfere
|
||||
await stopStreamingTranscription(true)
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Request microphone permission if needed (microphone should already be enabled by the user)
|
||||
if (!stream.value) {
|
||||
console.info('Requesting microphone permission', { source: 'useTranscriptions' })
|
||||
await askPermission()
|
||||
|
||||
// If still no stream, try starting it manually
|
||||
if (!stream.value && hearingEnabled.value) {
|
||||
console.info('Attempting to start stream manually', { source: 'useTranscriptions' })
|
||||
startStream()
|
||||
// Wait for the stream to become available with a timeout.
|
||||
try {
|
||||
await until(stream).toBeTruthy({ timeout: 3000, throwOnTimeout: true })
|
||||
}
|
||||
catch {
|
||||
console.error('Timed out waiting for audio stream. Stopping transcription.', { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to request microphone permission:', err, { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
}
|
||||
|
||||
if (!stream.value) {
|
||||
const errorMsg = 'Failed to get audio stream for transcription. Please check microphone permissions and ensure a device is selected.'
|
||||
console.error(errorMsg, { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.info('Starting streaming transcription with stream:', stream.value.id, { source: 'useTranscriptions' })
|
||||
|
||||
// Allow calling this even if already listening - transcribeForMediaStream will handle session reuse/restart
|
||||
// Call transcribeForMediaStream - it's async so we await it
|
||||
// Set listening state AFTER successful call
|
||||
try {
|
||||
await transcribeForMediaStream(stream.value, {
|
||||
onSentenceEnd: (delta) => {
|
||||
if (delta && delta.trim()) {
|
||||
console.info('Received transcription delta:', delta, { source: 'useTranscriptions' })
|
||||
// Append transcribed text to message input
|
||||
const currentText = messageInput.value.trim()
|
||||
messageInput.value = currentText ? `${currentText} ${delta}` : delta
|
||||
debouncedAutoSend()
|
||||
}
|
||||
},
|
||||
// Omit onSpeechEnd to avoid re-adding user-deleted text; use sentence deltas only.
|
||||
})
|
||||
|
||||
// Only set listening to true if transcription started successfully
|
||||
// (transcribeForMediaStream might return early if session already exists)
|
||||
isListening.value = true
|
||||
console.info('Streaming transcription initiated successfully', { source: 'useTranscriptions' })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Transcription error:', err, { source: 'useTranscriptions' })
|
||||
isListening.value = false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for auto-send setting changes and clear pending sends if disabled
|
||||
watch(autoSendEnabled, (enabled) => {
|
||||
if (!enabled) {
|
||||
clearPendingAutoSend()
|
||||
console.info('Auto-send disabled', { source: 'useTranscriptions' })
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for auto-send setting changes and clear pending sends if disabled
|
||||
watch(hearingEnabled, async (enabled) => {
|
||||
if (!enabled) {
|
||||
await stopStreaming()
|
||||
console.info('Stopping streaming transcription because hearing is disabled.', { source: 'useTranscriptions' })
|
||||
}
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
clearPendingAutoSend()
|
||||
stopStreaming()
|
||||
})
|
||||
|
||||
return {
|
||||
startStreamingTranscription: startStreaming,
|
||||
stopStreamingTranscription: stopStreaming,
|
||||
isListening,
|
||||
autoSendEnabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user