refactor(stage-*): improve transcription UX and DX (#1685)

This commit is contained in:
DrHuangMHT
2026-05-09 19:09:39 +08:00
committed by GitHub
parent 06ab58408b
commit 24da875aa6
11 changed files with 921 additions and 409 deletions
+3
View File
@@ -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,
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
},
})