refactor(stage-tamagotchi,stage-ui): improve input transcription pipeline (#2001)

---------

Co-authored-by-agent: Codex <codex@openai.com>
This commit is contained in:
Lovehsigure_520
2026-06-26 16:05:02 +08:00
committed by GitHub
parent 7f2579e072
commit 8c6e4576e1
29 changed files with 2657 additions and 148 deletions
@@ -3,7 +3,7 @@ import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&u
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { Alert, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAnalytics, useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables'
import { useAnalytics, useAudioAnalyzer, useAudioRecorder, useVoiceInputSession } from '@proj-airi/stage-ui/composables'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { CONFIDENCE_THRESHOLD_DISABLED, useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
@@ -81,12 +81,67 @@ 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.
@@ -105,6 +160,9 @@ async function handleSpeechStart() {
}
async function handleSpeechEnd() {
if (isTestingSTT.value)
return
if (shouldUseStreamInput.value) {
// For streaming providers, keep the session alive; idle timer will handle teardown.
return
@@ -264,38 +322,12 @@ onStopRecord(async (recording) => {
if (shouldUseStreamInput.value)
return
if (isTestingSTT.value)
return
if (!recording || recording.size === 0)
return
// Handle STT test transcription directly here
if (isTestingSTT.value) {
testStatusMessage.value = 'Transcribing recording...'
isTranscribing.value = true
try {
const result = await transcribeForRecording(recording)
if (result) {
testTranscriptionText.value = result
testStatusMessage.value = 'Transcription complete!'
console.info('STT test transcription result:', result)
}
else {
testTranscriptionError.value = transcriptionPipelineError.value || 'No transcription result returned from provider'
testStatusMessage.value = 'Transcription failed'
}
}
catch (err) {
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
console.error('STT test transcription error:', err)
}
finally {
isTranscribing.value = false
isTestingSTT.value = false
}
return
}
// Normal monitoring mode - add to audios and transcribe
audios.value.push(recording)
@@ -393,12 +425,29 @@ async function startSTTTest() {
testStatusMessage.value = 'Recording audio for transcription... (3 seconds)'
console.info('Starting STT test with recording-based transcription for provider:', activeTranscriptionProvider.value)
startRecord()
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
setTimeout(async () => {
stopRecord()
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
}
}
@@ -422,7 +471,7 @@ async function stopSTTTest() {
await stopStreamingTranscription(false, activeTranscriptionProvider.value)
}
else {
stopRecord()
await resetSttTestVoiceInputSession()
}
}
catch (err) {
@@ -446,9 +495,6 @@ async function stopSTTTest() {
}
}
// Note: STT test transcription is now handled directly in onStopRecord handler above
// This watch is kept for potential future use but is no longer needed for STT tests
watch(selectedAudioInput, async () => isMonitoring.value && await setupAudioMonitoring())
function handleStreamStartError() {
@@ -36,6 +36,7 @@ import { useSpeechPipelineAnalytics } from '../../composables/use-speech-pipelin
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
import { createStageTtsSession } from '../../libs/speech/tts-session'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useBackgroundStore } from '../../stores/background'
@@ -519,13 +520,19 @@ speechPipeline.on('onTurnCancel', ({ turnId }) => {
streamingControl.cancelTurn(turnId)
})
playbackManager.onEnd(() => {
function resetSpeakingState() {
nowSpeaking.value = false
mouthOpenSize.value = 0
})
}
playbackManager.onStart(({ item }) => {
bindSpeakingStateToPlaybackManager(playbackManager, {
setSpeaking: (speaking) => {
if (!speaking)
resetSpeakingState()
else
nowSpeaking.value = true
},
onStart: ({ item }) => {
// NOTICE: postCaption and postPresent may throw errors if the BroadcastChannel is closed
// (e.g., when navigating away from the page). We wrap these in try-catch to prevent
// breaking playback when the channel is unavailable.
@@ -542,6 +549,7 @@ playbackManager.onStart(({ item }) => {
catch {
// BroadcastChannel may be closed - don't break playback
}
},
})
function startLipSyncLoop() {
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const vueUseMock = vi.hoisted(() => ({
audioInputs: undefined as unknown as { value: MediaDeviceInfo[] },
ensurePermissions: vi.fn(async () => {}),
startUserMediaStream: vi.fn(),
stopStream: vi.fn(),
}))
vi.mock('@vueuse/core', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
vueUseMock.audioInputs = vue.ref<MediaDeviceInfo[]>([])
return {
useDevicesList: () => ({
audioInputs: vueUseMock.audioInputs,
permissionGranted: vue.ref(false),
ensurePermissions: vueUseMock.ensurePermissions,
}),
useUserMedia: ({ constraints }: { constraints: { value: MediaStreamConstraints } }) => ({
stream: vue.shallowRef<MediaStream>(),
stop: vueUseMock.stopStream,
start: () => vueUseMock.startUserMediaStream(constraints.value),
}),
}
})
function createAudioInput(deviceId: string): MediaDeviceInfo {
return {
deviceId,
groupId: '',
kind: 'audioinput',
label: deviceId,
toJSON: () => ({}),
}
}
function createDeviceNotFoundError() {
const error = new Error('Requested device not found')
error.name = 'NotFoundError'
return error
}
describe('useAudioDevice', () => {
afterEach(() => {
vueUseMock.audioInputs.value = []
vi.clearAllMocks()
})
it('recognizes browser device-not-found errors that are not Error instances', async () => {
const { isMissingAudioInputDeviceError } = await import('./audio-device')
expect(isMissingAudioInputDeviceError({ name: 'NotFoundError' })).toBe(true)
expect(isMissingAudioInputDeviceError({ message: 'Requested device not found' })).toBe(true)
})
it('retries with the system default microphone when a persisted device id is stale', async () => {
const { useAudioDevice } = await import('./audio-device')
const { selectedAudioInput, startStream } = useAudioDevice()
selectedAudioInput.value = 'stale-device-id'
vueUseMock.startUserMediaStream
.mockRejectedValueOnce(createDeviceNotFoundError())
.mockResolvedValueOnce(undefined)
await startStream()
expect(selectedAudioInput.value).toBe('')
expect(vueUseMock.startUserMediaStream).toHaveBeenNthCalledWith(1, {
audio: {
autoGainControl: true,
deviceId: { exact: 'stale-device-id' },
echoCancellation: true,
noiseSuppression: true,
},
})
expect(vueUseMock.startUserMediaStream).toHaveBeenNthCalledWith(2, {
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
})
it('prefers an enumerated default input before falling back to unconstrained audio', async () => {
vueUseMock.audioInputs.value = [
createAudioInput('default'),
createAudioInput('microphone-1'),
]
const { useAudioDevice } = await import('./audio-device')
const { selectedAudioInput, startStream } = useAudioDevice()
selectedAudioInput.value = 'stale-device-id'
vueUseMock.startUserMediaStream.mockResolvedValueOnce(undefined)
await startStream()
expect(selectedAudioInput.value).toBe('default')
expect(vueUseMock.startUserMediaStream).toHaveBeenCalledWith({
audio: {
autoGainControl: true,
deviceId: { exact: 'default' },
echoCancellation: true,
noiseSuppression: true,
},
})
})
})
@@ -1,32 +1,58 @@
import { useDevicesList, useUserMedia } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
function resolvePreferredAudioInput(audioInputs: MediaDeviceInfo[]) {
return audioInputs.find(device => device.deviceId === 'default')?.deviceId || audioInputs[0]?.deviceId || ''
}
export function isMissingAudioInputDeviceError(error: unknown) {
if (!error || typeof error !== 'object')
return false
const { message, name } = error as { message?: unknown, name?: unknown }
return name === 'NotFoundError'
|| name === 'OverconstrainedError'
|| (typeof message === 'string' && message.includes('Requested device not found'))
}
export function useAudioDevice(requestPermission: boolean = false) {
const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
const selectedAudioInput = ref<string>(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
function selectAvailableAudioInput() {
if (!audioInputs.value.length)
return
const selectedIsAvailable = audioInputs.value.some(device => device.deviceId === selectedAudioInput.value)
if (!selectedAudioInput.value || !selectedIsAvailable)
selectedAudioInput.value = resolvePreferredAudioInput(audioInputs.value)
}
const deviceConstraints = computed<MediaStreamConstraints>(() => ({
audio: {
audio: selectedAudioInput.value
? {
deviceId: { exact: selectedAudioInput.value },
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
}
: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
}))
const { stream, stop: stopStream, start: startStream } = useUserMedia({ constraints: deviceConstraints, enabled: false, autoSwitch: true })
const { stream, stop: stopStream, start: startUserMediaStream } = useUserMedia({ constraints: deviceConstraints, enabled: false, autoSwitch: true })
watch(audioInputs, () => {
if (selectedAudioInput.value === '' && audioInputs.value.length > 0) {
selectedAudioInput.value = audioInputs.value.find(input => input.deviceId === 'default')?.deviceId || audioInputs.value[0].deviceId
}
selectAvailableAudioInput()
})
function askPermission() {
return ensurePermissions()
.then(() => nextTick())
.then(() => {
if (audioInputs.value.length > 0 && !selectedAudioInput.value) {
selectedAudioInput.value = audioInputs.value.find(input => input.deviceId === 'default')?.deviceId || audioInputs.value[0].deviceId
}
selectAvailableAudioInput()
})
.catch((error) => {
console.error('Error ensuring permissions:', error)
@@ -34,6 +60,30 @@ export function useAudioDevice(requestPermission: boolean = false) {
})
}
async function startStream() {
selectAvailableAudioInput()
try {
return await startUserMediaStream()
}
catch (error) {
const fallbackDeviceId = resolvePreferredAudioInput(audioInputs.value)
if (fallbackDeviceId && fallbackDeviceId !== selectedAudioInput.value) {
selectedAudioInput.value = fallbackDeviceId
await nextTick()
return await startUserMediaStream()
}
if (selectedAudioInput.value && isMissingAudioInputDeviceError(error)) {
selectedAudioInput.value = ''
await nextTick()
return await startUserMediaStream()
}
throw error
}
}
return {
audioInputs,
selectedAudioInput,
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import { shallowRef } from 'vue'
const mediabunnyMock = vi.hoisted(() => {
const audioSources: Array<{ track: MediaStreamTrack, encodingConfig: { codec: string, bitrate: number } }> = []
const outputs: Array<{ target: { buffer?: Uint8Array }, finalized: boolean }> = []
let startFailuresRemaining = 0
class FakeBufferTarget {
buffer?: Uint8Array
}
class FakeWavOutputFormat {}
class FakeMediaStreamAudioTrackSource {
errorPromise = new Promise<void>(() => {})
constructor(track: MediaStreamTrack, encodingConfig: { codec: string, bitrate: number }) {
audioSources.push({ track, encodingConfig })
}
}
class FakeOutput {
target: FakeBufferTarget
finalized = false
constructor(options: { target: FakeBufferTarget }) {
this.target = options.target
outputs.push(this)
}
addAudioTrack() {}
async getMimeType() {
return 'audio/wav'
}
async start() {
if (startFailuresRemaining > 0) {
startFailuresRemaining -= 1
throw new Error('start failed')
}
this.target.buffer = new Uint8Array([outputs.length])
}
async finalize() {
this.finalized = true
}
}
return {
audioSources,
outputs,
failNextStart: () => {
startFailuresRemaining += 1
},
FakeBufferTarget,
FakeMediaStreamAudioTrackSource,
FakeOutput,
FakeWavOutputFormat,
}
})
vi.mock('mediabunny', () => ({
BufferTarget: mediabunnyMock.FakeBufferTarget,
MediaStreamAudioTrackSource: mediabunnyMock.FakeMediaStreamAudioTrackSource,
Output: mediabunnyMock.FakeOutput,
QUALITY_MEDIUM: 1,
WavOutputFormat: mediabunnyMock.FakeWavOutputFormat,
}))
function createMediaStream() {
return {
getAudioTracks: () => ([{} as MediaStreamTrack]),
} as MediaStream
}
describe('useAudioRecorder', () => {
it('records WAV audio with 16-bit PCM for transcription providers', async () => {
const { useAudioRecorder } = await import('./audio-recorder')
const stream = shallowRef(createMediaStream())
const { startRecord } = useAudioRecorder(stream)
await startRecord()
expect(mediabunnyMock.audioSources.at(-1)?.encodingConfig).toEqual({
codec: 'pcm-s16',
bitrate: 1,
})
})
it('keeps a new recording active while previous stop hooks finish', async () => {
const { useAudioRecorder } = await import('./audio-recorder')
const stream = shallowRef(createMediaStream())
const { startRecord, stopRecord, onStopRecord, isRecording } = useAudioRecorder(stream)
let resolveFirstHook!: () => void
let shouldBlockHook = true
onStopRecord(async () => {
if (!shouldBlockHook)
return
shouldBlockHook = false
await new Promise<void>((resolve) => {
resolveFirstHook = resolve
})
})
await startRecord()
expect(isRecording.value).toBe(true)
const firstStop = stopRecord()
await Promise.resolve()
expect(isRecording.value).toBe(false)
await startRecord()
expect(isRecording.value).toBe(true)
const activeSecondOutput = mediabunnyMock.outputs.at(-1)
resolveFirstHook()
await firstStop
await stopRecord()
expect(isRecording.value).toBe(false)
expect(activeSecondOutput?.finalized).toBe(true)
})
it('resets recorder state after startup fails so recording can be retried', async () => {
const { useAudioRecorder } = await import('./audio-recorder')
const stream = shallowRef(createMediaStream())
const { startRecord, isRecording } = useAudioRecorder(stream)
mediabunnyMock.failNextStart()
await expect(startRecord()).rejects.toThrow('start failed')
expect(isRecording.value).toBe(false)
await startRecord()
expect(isRecording.value).toBe(true)
})
})
@@ -2,8 +2,13 @@ import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { BufferTarget, MediaStreamAudioTrackSource, Output, QUALITY_MEDIUM, WavOutputFormat } from 'mediabunny'
import { ref, shallowRef, toRef } from 'vue'
import { computed, ref, shallowRef, toRef } from 'vue'
const TRANSCRIPTION_WAV_CODEC = 'pcm-s16'
/**
* Returns the first audio track from the active microphone stream.
*/
function getMediaStreamTrack(stream: MediaStream) {
const tracks = stream.getAudioTracks()
if (!tracks.length)
@@ -11,17 +16,24 @@ function getMediaStreamTrack(stream: MediaStream) {
return tracks[0]
}
/**
* Records microphone input into short WAV blobs for transcription providers.
*/
export function useAudioRecorder(
media: MaybeRefOrGetter<MediaStream | undefined>,
) {
const mediaRef = toRef(media)
const recording = shallowRef<Blob>()
const mediaOutput = ref<Output>()
const mediaFormat = ref<string>()
const mediaOutput = shallowRef<Output>()
const mediaFormat = shallowRef<string>()
const isRecording = computed(() => !!mediaOutput.value)
const onStopRecordHooks = ref<Array<(recording: Blob | undefined) => Promise<void>>>([])
/**
* Registers a callback that receives each finalized recording blob.
*/
function onStopRecord(callback: (recording: Blob | undefined) => Promise<void>) {
onStopRecordHooks.value.push(callback)
// Return unsubscribe function to prevent memory leaks
@@ -30,29 +42,55 @@ export function useAudioRecorder(
}
}
/**
* Starts recording from the current microphone stream if no recording is active.
*/
async function startRecord() {
if (mediaOutput.value)
return
await until(mediaRef).toBeTruthy()
const track = await getMediaStreamTrack(mediaRef.value!)
mediaOutput.value = new Output({ format: new WavOutputFormat(), target: new BufferTarget() })
const output = new Output({ format: new WavOutputFormat(), target: new BufferTarget() })
mediaOutput.value = output
const audioSource = new MediaStreamAudioTrackSource(track, { codec: 'pcm-f32', bitrate: QUALITY_MEDIUM })
try {
const audioSource = new MediaStreamAudioTrackSource(track, { codec: TRANSCRIPTION_WAV_CODEC, bitrate: QUALITY_MEDIUM })
audioSource.errorPromise.catch(console.error)
mediaOutput.value.addAudioTrack(audioSource)
output.addAudioTrack(audioSource)
mediaFormat.value = await mediaOutput.value.getMimeType()
await mediaOutput.value.start()
mediaFormat.value = await output.getMimeType()
await output.start()
}
catch (error) {
if (mediaOutput.value === output) {
mediaOutput.value = undefined
mediaFormat.value = undefined
}
throw error
}
}
/**
* Finalizes the active recording and runs stop hooks without blocking the next recording.
*/
async function stopRecord() {
if (!mediaOutput.value) {
const activeOutput = mediaOutput.value
const activeFormat = mediaFormat.value
if (!activeOutput) {
return
}
await mediaOutput.value.finalize()
const bufferTarget = mediaOutput.value.target as BufferTarget | undefined
// Clear the active output before running transcription hooks so VAD can start the next utterance
// while the previous blob is still being sent to the ASR provider.
mediaOutput.value = undefined
mediaFormat.value = undefined
await activeOutput.finalize()
const bufferTarget = activeOutput.target as BufferTarget | undefined
const buffer = bufferTarget?.buffer
const audioBlob = buffer ? new Blob([buffer], { type: mediaFormat.value }) : undefined
const audioBlob = buffer ? new Blob([buffer], { type: activeFormat }) : undefined
recording.value = audioBlob
@@ -66,8 +104,6 @@ export function useAudioRecorder(
}
}
mediaOutput.value = undefined
return audioBlob
}
@@ -76,6 +112,7 @@ export function useAudioRecorder(
stopRecord,
onStopRecord,
isRecording,
recording,
}
}
@@ -2,3 +2,4 @@ export * from './audio-analyzer'
export * from './audio-context'
export * from './audio-device'
export * from './audio-recorder'
export * from './voice-input-session'
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
createVoiceInputRecordingSegment,
resolveActiveVoiceInputRecordingSegmentAfterStop,
} from './voice-input-segment'
describe('voice input recording segment tracking', () => {
it('does not clear a newer active segment when an older segment finishes stopping', () => {
const stoppedSegment = createVoiceInputRecordingSegment(1, 'volume')
const newerActiveSegment = createVoiceInputRecordingSegment(2, 'vad')
expect(resolveActiveVoiceInputRecordingSegmentAfterStop(newerActiveSegment, stoppedSegment))
.toBe(newerActiveSegment)
})
it('clears the active segment when the stopped segment is still current', () => {
const stoppedSegment = createVoiceInputRecordingSegment(1, 'manual')
expect(resolveActiveVoiceInputRecordingSegmentAfterStop(stoppedSegment, stoppedSegment))
.toBeUndefined()
})
})
@@ -0,0 +1,26 @@
export type VoiceInputSessionTrigger = 'manual' | 'vad' | 'volume'
export interface VoiceInputRecordingSegment {
id: number
trigger: VoiceInputSessionTrigger
}
export function createVoiceInputRecordingSegment(id: number, trigger: VoiceInputSessionTrigger): VoiceInputRecordingSegment {
return { id, trigger }
}
function isSameVoiceInputRecordingSegment(
left: VoiceInputRecordingSegment | undefined,
right: VoiceInputRecordingSegment | undefined,
) {
return !!left && !!right && left.id === right.id
}
export function resolveActiveVoiceInputRecordingSegmentAfterStop(
activeSegment: VoiceInputRecordingSegment | undefined,
stoppedSegment: VoiceInputRecordingSegment | undefined,
) {
return isSameVoiceInputRecordingSegment(activeSegment, stoppedSegment)
? undefined
: activeSegment
}
@@ -0,0 +1,292 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref, shallowRef } from 'vue'
const audioRecorderMock = vi.hoisted(() => ({
isRecording: undefined as unknown as { value: boolean },
startRecord: vi.fn(),
stopRecord: vi.fn(),
}))
vi.mock('../../workers/vad/process.worklet?worker&url', () => ({
default: 'vad-worklet-url',
}))
vi.mock('../../stores/ai/models/vad', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
return {
useVAD: () => ({
init: vi.fn(),
dispose: vi.fn(),
start: vi.fn(),
loaded: vue.ref(true),
isSpeech: vue.ref(false),
isSpeechProb: vue.ref(0),
isSpeechHistory: vue.ref([]),
inferenceError: vue.ref(),
}),
}
})
vi.mock('../../stores/modules/hearing', () => ({
useHearingSpeechInputPipeline: () => ({
transcribeForRecording: vi.fn(async () => ''),
}),
}))
vi.mock('./audio-recorder', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
audioRecorderMock.isRecording = vue.ref(false)
return {
useAudioRecorder: () => ({
isRecording: audioRecorderMock.isRecording,
startRecord: audioRecorderMock.startRecord,
stopRecord: audioRecorderMock.stopRecord,
onStopRecord: vi.fn(),
}),
}
})
function createMediaStream() {
return {
getAudioTracks: () => ([{} as MediaStreamTrack]),
} as MediaStream
}
describe('useVoiceInputSession', () => {
afterEach(() => {
audioRecorderMock.isRecording.value = false
vi.useRealTimers()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('clears the active recorder segment when discarding fails during stop', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
audioRecorderMock.startRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = true
})
audioRecorderMock.stopRecord.mockImplementationOnce(async () => {
audioRecorderMock.isRecording.value = false
throw new Error('finalize failed')
})
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
})
await expect(session.startSegment('manual')).resolves.toBe(true)
expect(session.activeRecordingTrigger.value).toBe('manual')
await expect(session.stop({ flushActiveRecording: false })).rejects.toThrow('finalize failed')
expect(session.activeRecordingTrigger.value).toBeUndefined()
})
it('reports a failed recorder start without leaving an active segment', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const startupError = new Error('start failed')
audioRecorderMock.startRecord.mockRejectedValueOnce(startupError)
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
})
await expect(session.startSegment('manual')).resolves.toBe(false)
expect(session.activeRecordingTrigger.value).toBeUndefined()
expect(session.lastError.value).toBe(startupError)
})
it('clears the active segment when the caller start gate rejects', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const gateError = new Error('gate failed')
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
canStartSegment: vi.fn()
.mockRejectedValueOnce(gateError)
.mockResolvedValueOnce(true),
})
await expect(session.startSegment('manual')).resolves.toBe(false)
expect(session.activeRecordingTrigger.value).toBeUndefined()
expect(session.lastError.value).toBe(gateError)
audioRecorderMock.startRecord.mockImplementationOnce(async () => {
audioRecorderMock.isRecording.value = true
})
await expect(session.startSegment('manual')).resolves.toBe(true)
expect(session.activeRecordingTrigger.value).toBe('manual')
})
it('clears the active segment when the caller start hook rejects', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const hookError = new Error('start hook failed')
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
onSegmentStart: vi.fn().mockRejectedValueOnce(hookError),
})
await expect(session.startSegment('manual')).resolves.toBe(false)
expect(audioRecorderMock.startRecord).not.toHaveBeenCalled()
expect(session.activeRecordingTrigger.value).toBeUndefined()
expect(session.lastError.value).toBe(hookError)
})
it('stops and clears the recorder when the caller started hook rejects', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const hookError = new Error('started hook failed')
audioRecorderMock.startRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = true
})
audioRecorderMock.stopRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = false
})
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
onSegmentStarted: vi.fn().mockRejectedValueOnce(hookError),
})
await expect(session.startSegment('manual')).resolves.toBe(false)
expect(audioRecorderMock.stopRecord).toHaveBeenCalledOnce()
expect(session.isRecording.value).toBe(false)
expect(session.activeRecordingTrigger.value).toBeUndefined()
expect(session.lastError.value).toBe(hookError)
})
it('finalizes the recorder when the caller stop hook rejects', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const hookError = new Error('stop hook failed')
const onTranscriptionError = vi.fn()
audioRecorderMock.startRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = true
})
audioRecorderMock.stopRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = false
})
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: { enabled: false },
onSegmentStop: vi.fn().mockRejectedValueOnce(hookError),
onTranscriptionError,
})
await expect(session.startSegment('manual')).resolves.toBe(true)
await expect(session.stopSegment('manual')).resolves.toBeUndefined()
expect(audioRecorderMock.stopRecord).toHaveBeenCalledOnce()
expect(session.isRecording.value).toBe(false)
expect(session.activeRecordingTrigger.value).toBeUndefined()
expect(session.lastError.value).toBe(hookError)
expect(onTranscriptionError).toHaveBeenCalledWith(expect.objectContaining({ error: hookError }))
})
it('stops an active recorder segment after stream mode becomes enabled', async () => {
const { useVoiceInputSession } = await import('./voice-input-session')
const shouldUseStreamInput = ref(false)
audioRecorderMock.startRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = true
})
audioRecorderMock.stopRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = false
})
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
shouldUseStreamInput,
volumeFallback: { enabled: false },
})
await expect(session.startSegment('manual')).resolves.toBe(true)
shouldUseStreamInput.value = true
await session.stopSegment('manual')
expect(audioRecorderMock.stopRecord).toHaveBeenCalledOnce()
expect(session.isRecording.value).toBe(false)
expect(session.activeRecordingTrigger.value).toBeUndefined()
})
it('lets volume fallback finalize a VAD-owned segment after silence', async () => {
vi.useFakeTimers()
vi.setSystemTime(1000)
const animationFrames: FrameRequestCallback[] = []
const stopRecord = audioRecorderMock.stopRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = false
})
audioRecorderMock.startRecord.mockImplementation(async () => {
audioRecorderMock.isRecording.value = true
})
class FakeAudioContext {
state: AudioContextState = 'running'
destination = {}
createMediaStreamSource() {
return {
connect: vi.fn(),
disconnect: vi.fn(),
}
}
createAnalyser() {
return {
fftSize: 512,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getByteTimeDomainData: (data: Uint8Array<ArrayBuffer>) => data.fill(128),
}
}
createGain() {
return {
gain: { value: 1 },
connect: vi.fn(),
disconnect: vi.fn(),
}
}
resume = vi.fn()
close = vi.fn()
}
vi.stubGlobal('AudioContext', FakeAudioContext)
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
}))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const { useVoiceInputSession } = await import('./voice-input-session')
const session = useVoiceInputSession(shallowRef(createMediaStream()), {
volumeFallback: {
enabled: true,
stopDelayMs: 10,
},
})
await expect(session.startSegment('vad')).resolves.toBe(true)
await session.startAutoSegmentation()
animationFrames.shift()?.(1000)
vi.setSystemTime(1011)
animationFrames.shift()?.(1011)
await Promise.resolve()
expect(stopRecord).toHaveBeenCalledOnce()
expect(session.activeRecordingTrigger.value).toBeUndefined()
})
})
@@ -0,0 +1,589 @@
import type { MaybeRefOrGetter } from 'vue'
import type { VoiceInputRecordingSegment, VoiceInputSessionTrigger } from './voice-input-segment'
import type { VoiceInputTranscriptionTicket } from './voice-input-transcription-chain'
import { computed, ref, shallowRef, toRef } from 'vue'
import workletUrl from '../../workers/vad/process.worklet?worker&url'
import { useVAD } from '../../stores/ai/models/vad'
import { useHearingSpeechInputPipeline } from '../../stores/modules/hearing'
import { useAudioRecorder } from './audio-recorder'
import {
createVoiceInputRecordingSegment,
resolveActiveVoiceInputRecordingSegmentAfterStop,
} from './voice-input-segment'
import { createVoiceInputTranscriptionChain } from './voice-input-transcription-chain'
import { startVoiceInputVadDetectionSafely } from './voice-input-vad-startup'
export type { VoiceInputSessionTrigger } from './voice-input-segment'
export type VoiceInputSessionLogLevel = 'info' | 'warn' | 'error'
export interface VoiceInputSessionGate {
skip?: boolean
reason?: string
details?: Record<string, unknown>
}
export interface VoiceInputSessionEvent {
trigger: VoiceInputSessionTrigger
recording?: Blob
text?: string
error?: unknown
metadata?: Record<string, unknown>
gate?: VoiceInputSessionGate
}
export interface VoiceInputSessionVadOptions {
threshold?: MaybeRefOrGetter<number>
minSilenceDurationMs?: MaybeRefOrGetter<number>
speechPadMs?: MaybeRefOrGetter<number>
minSpeechDurationMs?: MaybeRefOrGetter<number>
}
export interface VoiceInputSessionVolumeFallbackOptions {
enabled?: MaybeRefOrGetter<boolean>
startThreshold?: number
stopThreshold?: number
startFrames?: number
stopDelayMs?: number
logIntervalMs?: number
}
export interface VoiceInputSessionOptions {
shouldUseStreamInput?: MaybeRefOrGetter<boolean>
vad?: VoiceInputSessionVadOptions
volumeFallback?: VoiceInputSessionVolumeFallbackOptions
canStartSegment?: (event: VoiceInputSessionEvent) => boolean | Promise<boolean>
inspectBeforeTranscription?: (event: VoiceInputSessionEvent) => VoiceInputSessionGate | Promise<VoiceInputSessionGate | undefined> | undefined
inspectAfterTranscription?: (event: VoiceInputSessionEvent) => VoiceInputSessionGate | Promise<VoiceInputSessionGate | undefined> | undefined
onLog?: (level: VoiceInputSessionLogLevel, event: string, message: string, details?: Record<string, unknown>) => void
onSegmentStart?: (event: VoiceInputSessionEvent) => void | Promise<void>
onSegmentStarted?: (event: VoiceInputSessionEvent) => void | Promise<void>
onSegmentStop?: (event: VoiceInputSessionEvent) => void | Promise<void>
onSegmentStopped?: (event: VoiceInputSessionEvent) => void | Promise<void>
onRecordingReady?: (event: VoiceInputSessionEvent) => Record<string, unknown> | void | Promise<Record<string, unknown> | void>
onRecordingSkipped?: (event: VoiceInputSessionEvent) => void | Promise<void>
onTranscriptionStart?: (event: VoiceInputSessionEvent) => void | Promise<void>
onTranscriptionResult?: (event: VoiceInputSessionEvent & { text: string }) => void | Promise<void>
onTranscriptionEmpty?: (event: VoiceInputSessionEvent & { text: string }) => void | Promise<void>
onTranscriptionError?: (event: VoiceInputSessionEvent & { error: unknown }) => void | Promise<void>
}
const DEFAULT_VOLUME_FALLBACK_START_THRESHOLD = 10
const DEFAULT_VOLUME_FALLBACK_STOP_THRESHOLD = 6
const DEFAULT_VOLUME_FALLBACK_START_FRAMES = 4
const DEFAULT_VOLUME_FALLBACK_STOP_DELAY_MS = 900
const DEFAULT_VOLUME_FALLBACK_LOG_INTERVAL_MS = 2000
function calculateTimeDomainVolumeLevel(dataArray: Uint8Array<ArrayBuffer>) {
let sum = 0
for (let i = 0; i < dataArray.length; i++) {
const centered = (dataArray[i] - 128) / 128
sum += centered * centered
}
return Math.min(100, Math.sqrt(sum / dataArray.length) * 100 * 3)
}
/**
* Shared voice-input session for both manual STT tests and always-on stage listening.
*
* Owns:
* - recorder-backed segment creation
* - VAD-triggered auto segmentation
* - volume-triggered fallback segmentation
* - record-then-transcribe ASR calls
*
* Leaves product-specific behavior, such as sending text to chat or updating UI state, to callbacks.
*/
export function useVoiceInputSession(
media: MaybeRefOrGetter<MediaStream | undefined>,
options: VoiceInputSessionOptions = {},
) {
const mediaRef = toRef(media)
const shouldUseStreamInput = toRef(options.shouldUseStreamInput ?? false)
const volumeFallbackEnabled = toRef(options.volumeFallback?.enabled ?? true)
const hearingPipeline = useHearingSpeechInputPipeline()
const { transcribeForRecording } = hearingPipeline
const recorder = useAudioRecorder(mediaRef)
const activeRecordingSegment = shallowRef<VoiceInputRecordingSegment>()
const activeRecordingTrigger = computed(() => activeRecordingSegment.value?.trigger)
const isTranscribing = ref(false)
const lastTranscriptionText = ref('')
const lastError = ref<unknown>()
const transcriptionChain = createVoiceInputTranscriptionChain()
const stoppedRecordingSegments: VoiceInputRecordingSegment[] = []
let nextRecordingSegmentId = 0
let discardNextRecording = false
let activeTranscriptionCount = 0
const {
init: initVAD,
dispose: disposeVAD,
start: startVAD,
loaded: vadLoaded,
isSpeech: isSpeechVAD,
isSpeechProb,
isSpeechHistory,
inferenceError: vadError,
} = useVAD(workletUrl, {
threshold: options.vad?.threshold,
minSilenceDurationMs: options.vad?.minSilenceDurationMs,
speechPadMs: options.vad?.speechPadMs,
minSpeechDurationMs: options.vad?.minSpeechDurationMs,
onSpeechStart: () => {
void startSegment('vad')
},
onSpeechEnd: () => {
void stopSegment('vad')
},
})
let volumeFallbackAudioContext: AudioContext | undefined
let volumeFallbackSourceNode: MediaStreamAudioSourceNode | undefined
let volumeFallbackAnalyserNode: AnalyserNode | undefined
let volumeFallbackSilentGainNode: GainNode | undefined
let volumeFallbackDataArray: Uint8Array<ArrayBuffer> | undefined
let volumeFallbackAnimationFrame: number | undefined
let volumeFallbackSpeechFrames = 0
let volumeFallbackLastSpeechAt = 0
let volumeFallbackLastLogAt = 0
const isRecording = computed(() => recorder.isRecording.value)
function log(level: VoiceInputSessionLogLevel, event: string, message: string, details?: Record<string, unknown>) {
options.onLog?.(level, event, message, details)
}
function markTranscriptionStarted() {
activeTranscriptionCount += 1
isTranscribing.value = true
}
function markTranscriptionFinished() {
activeTranscriptionCount = Math.max(0, activeTranscriptionCount - 1)
isTranscribing.value = activeTranscriptionCount > 0
}
function isStaleTranscriptionTicket(ticket: VoiceInputTranscriptionTicket, trigger: VoiceInputSessionTrigger, phase: string) {
if (ticket.isCurrent())
return false
log('info', 'recording-drop-stale-session', 'Dropping stale recorder-backed transcription work after the listening session changed.', {
trigger,
phase,
})
return true
}
async function discardActiveRecorderSegment(segment: VoiceInputRecordingSegment) {
discardNextRecording = true
try {
await recorder.stopRecord()
}
finally {
discardNextRecording = false
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, segment)
}
}
async function startSegment(trigger: VoiceInputSessionTrigger = 'manual') {
const event: VoiceInputSessionEvent = { trigger }
if (shouldUseStreamInput.value) {
log('info', 'segment-start-skipped-streaming', 'Recorder segment start skipped because streaming transcription is active.', { trigger })
return false
}
if (isRecording.value || activeRecordingSegment.value) {
log('info', 'segment-start-skipped-active', 'Recorder segment start skipped because another segment is already active.', {
trigger,
activeRecordingTrigger: activeRecordingTrigger.value,
})
return false
}
const segment = createVoiceInputRecordingSegment(++nextRecordingSegmentId, trigger)
activeRecordingSegment.value = segment
if (options.canStartSegment) {
try {
if (!await options.canStartSegment(event)) {
log('info', 'segment-start-skipped-gate', 'Recorder segment start skipped by caller gate.', { trigger })
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, segment)
return false
}
}
catch (error) {
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, segment)
lastError.value = error
log('error', 'segment-start-gate-failed', 'Recorder segment start gate failed.', { trigger, error })
await options.onTranscriptionError?.({ trigger, error })
return false
}
}
try {
await options.onSegmentStart?.(event)
await recorder.startRecord()
try {
await options.onSegmentStarted?.(event)
}
catch (error) {
await discardActiveRecorderSegment(segment)
throw error
}
return true
}
catch (error) {
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, segment)
lastError.value = error
log('error', 'segment-start-failed', 'Failed to start recorder-backed voice input segment.', { trigger, error })
await options.onTranscriptionError?.({ trigger, error })
return false
}
}
async function stopSegment(trigger: VoiceInputSessionTrigger = 'manual') {
const event: VoiceInputSessionEvent = { trigger }
const segment = activeRecordingSegment.value
if (shouldUseStreamInput.value && !isRecording.value && !segment) {
log('info', 'segment-stop-skipped-streaming', 'Recorder segment stop skipped because streaming transcription is active.', { trigger })
return
}
if (segment && segment.trigger !== trigger) {
log('info', 'segment-stop-skipped-trigger-mismatch', 'Recorder segment stop skipped because another detector owns the active segment.', {
trigger,
activeRecordingTrigger: activeRecordingTrigger.value,
})
return
}
if (!isRecording.value) {
log('warn', 'segment-stop-without-active-recorder', 'Recorder segment stop requested without an active recording.', { trigger })
return
}
const stoppedSegment = segment ?? createVoiceInputRecordingSegment(++nextRecordingSegmentId, trigger)
try {
await options.onSegmentStop?.(event)
}
catch (error) {
lastError.value = error
log('error', 'segment-stop-hook-failed', 'Caller stop hook failed; finalizing recorder segment anyway.', { trigger, error })
await options.onTranscriptionError?.({ trigger, error })
}
try {
stoppedRecordingSegments.push(stoppedSegment)
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, stoppedSegment)
await recorder.stopRecord()
await options.onSegmentStopped?.(event)
}
catch (error) {
const queuedIndex = stoppedRecordingSegments.findIndex(item => item.id === stoppedSegment.id)
if (queuedIndex !== -1)
stoppedRecordingSegments.splice(queuedIndex, 1)
lastError.value = error
log('error', 'segment-stop-failed', 'Failed to stop recorder-backed voice input segment.', { trigger, error })
await options.onTranscriptionError?.({ trigger, error })
}
finally {
activeRecordingSegment.value = resolveActiveVoiceInputRecordingSegmentAfterStop(activeRecordingSegment.value, stoppedSegment)
}
}
async function processRecording(recording: Blob | undefined, trigger: VoiceInputSessionTrigger, ticket: VoiceInputTranscriptionTicket) {
const event: VoiceInputSessionEvent = { trigger, recording }
if (isStaleTranscriptionTicket(ticket, trigger, 'recording-start'))
return
if (!recording || recording.size <= 0) {
log('warn', 'recording-drop-empty', 'Dropping empty recorder-backed voice input segment.', { trigger, recording })
await options.onRecordingSkipped?.(event)
return
}
const metadata = await options.onRecordingReady?.(event) ?? undefined
const readyEvent = { ...event, metadata }
if (isStaleTranscriptionTicket(ticket, trigger, 'recording-ready'))
return
const beforeGate = await options.inspectBeforeTranscription?.(readyEvent)
if (isStaleTranscriptionTicket(ticket, trigger, 'before-transcription-gate'))
return
if (beforeGate?.skip) {
log('info', 'recording-drop-before-asr', 'Skipping recorder-backed segment before transcription request.', {
trigger,
gate: beforeGate,
})
await options.onRecordingSkipped?.({ ...readyEvent, gate: beforeGate })
return
}
markTranscriptionStarted()
let text = ''
try {
await options.onTranscriptionStart?.(readyEvent)
if (isStaleTranscriptionTicket(ticket, trigger, 'transcription-started'))
return
text = await transcribeForRecording(recording) ?? ''
}
catch (error) {
if (isStaleTranscriptionTicket(ticket, trigger, 'transcription-error'))
return
lastError.value = error
log('error', 'recording-transcription-error', 'Transcription provider threw while processing recorder-backed segment.', { trigger, error })
await options.onTranscriptionError?.({ ...readyEvent, error })
return
}
finally {
markTranscriptionFinished()
}
if (isStaleTranscriptionTicket(ticket, trigger, 'transcription-result'))
return
const resultEvent = { ...readyEvent, text }
const afterGate = await options.inspectAfterTranscription?.(resultEvent)
if (isStaleTranscriptionTicket(ticket, trigger, 'after-transcription-gate'))
return
if (afterGate?.skip) {
log('info', 'recording-drop-after-asr', 'Dropping stale transcription result after transcription request.', {
trigger,
gate: afterGate,
text,
})
await options.onRecordingSkipped?.({ ...resultEvent, gate: afterGate })
return
}
if (!text || !text.trim()) {
log('warn', 'recording-transcription-empty', 'Transcription provider returned empty text for recorder-backed segment.', { trigger, text })
await options.onTranscriptionEmpty?.(resultEvent)
return
}
lastTranscriptionText.value = text
await options.onTranscriptionResult?.(resultEvent)
}
recorder.onStopRecord(async (recording) => {
if (discardNextRecording) {
discardNextRecording = false
return
}
const segment = stoppedRecordingSegments.shift()
const trigger = segment?.trigger ?? activeRecordingTrigger.value ?? 'manual'
await transcriptionChain
.enqueue(ticket => processRecording(recording, trigger, ticket))
.catch((error) => {
lastError.value = error
log('error', 'recording-processing-error', 'Voice input recording processing failed.', { trigger, error })
})
})
function stopVolumeFallback() {
if (volumeFallbackAnimationFrame !== undefined) {
cancelAnimationFrame(volumeFallbackAnimationFrame)
volumeFallbackAnimationFrame = undefined
}
volumeFallbackSourceNode?.disconnect()
volumeFallbackAnalyserNode?.disconnect()
volumeFallbackSilentGainNode?.disconnect()
volumeFallbackSourceNode = undefined
volumeFallbackAnalyserNode = undefined
volumeFallbackSilentGainNode = undefined
volumeFallbackDataArray = undefined
volumeFallbackSpeechFrames = 0
volumeFallbackLastSpeechAt = 0
volumeFallbackLastLogAt = 0
if (volumeFallbackAudioContext && volumeFallbackAudioContext.state !== 'closed')
void volumeFallbackAudioContext.close()
volumeFallbackAudioContext = undefined
}
async function startVolumeFallback(stream: MediaStream) {
if (!volumeFallbackEnabled.value || shouldUseStreamInput.value)
return
stopVolumeFallback()
const startThreshold = options.volumeFallback?.startThreshold ?? DEFAULT_VOLUME_FALLBACK_START_THRESHOLD
const stopThreshold = options.volumeFallback?.stopThreshold ?? DEFAULT_VOLUME_FALLBACK_STOP_THRESHOLD
const startFrames = options.volumeFallback?.startFrames ?? DEFAULT_VOLUME_FALLBACK_START_FRAMES
const stopDelayMs = options.volumeFallback?.stopDelayMs ?? DEFAULT_VOLUME_FALLBACK_STOP_DELAY_MS
const logIntervalMs = options.volumeFallback?.logIntervalMs ?? DEFAULT_VOLUME_FALLBACK_LOG_INTERVAL_MS
try {
volumeFallbackAudioContext = new AudioContext({ latencyHint: 'interactive' })
if (volumeFallbackAudioContext.state === 'suspended')
await volumeFallbackAudioContext.resume()
volumeFallbackSourceNode = volumeFallbackAudioContext.createMediaStreamSource(stream)
volumeFallbackAnalyserNode = volumeFallbackAudioContext.createAnalyser()
volumeFallbackAnalyserNode.fftSize = 512
volumeFallbackAnalyserNode.smoothingTimeConstant = 0.25
volumeFallbackSilentGainNode = volumeFallbackAudioContext.createGain()
volumeFallbackSilentGainNode.gain.value = 0
volumeFallbackDataArray = new Uint8Array(volumeFallbackAnalyserNode.fftSize) as Uint8Array<ArrayBuffer>
volumeFallbackSourceNode.connect(volumeFallbackAnalyserNode)
volumeFallbackAnalyserNode.connect(volumeFallbackSilentGainNode)
volumeFallbackSilentGainNode.connect(volumeFallbackAudioContext.destination)
log('info', 'volume-fallback-started', 'Volume-based recorder fallback started for record-then-transcribe voice input.', {
startThreshold,
stopThreshold,
stopDelayMs,
})
const analyze = () => {
if (!volumeFallbackAnalyserNode || !volumeFallbackDataArray)
return
volumeFallbackAnalyserNode.getByteTimeDomainData(volumeFallbackDataArray)
const level = calculateTimeDomainVolumeLevel(volumeFallbackDataArray)
const now = Date.now()
if (now - volumeFallbackLastLogAt >= logIntervalMs) {
volumeFallbackLastLogAt = now
log('info', 'volume-fallback-level', 'Volume fallback sampled microphone input.', {
level: Number(level.toFixed(1)),
isRecording: isRecording.value,
activeRecordingTrigger: activeRecordingTrigger.value,
startThreshold,
stopThreshold,
})
}
if (shouldUseStreamInput.value) {
volumeFallbackSpeechFrames = 0
volumeFallbackAnimationFrame = requestAnimationFrame(analyze)
return
}
if (!isRecording.value) {
if (level >= startThreshold) {
volumeFallbackSpeechFrames += 1
if (volumeFallbackSpeechFrames >= startFrames) {
volumeFallbackLastSpeechAt = now
volumeFallbackSpeechFrames = 0
log('info', 'volume-fallback-speech-start', 'Volume fallback detected speech; starting recorder segment.', {
level: Number(level.toFixed(1)),
})
void startSegment('volume')
}
}
else {
volumeFallbackSpeechFrames = 0
}
}
else if (activeRecordingTrigger.value === 'volume' || activeRecordingTrigger.value === 'vad') {
if (level > stopThreshold) {
volumeFallbackLastSpeechAt = now
}
else if (!volumeFallbackLastSpeechAt) {
volumeFallbackLastSpeechAt = now
}
else if (volumeFallbackLastSpeechAt && now - volumeFallbackLastSpeechAt >= stopDelayMs) {
const trigger = activeRecordingTrigger.value
volumeFallbackLastSpeechAt = 0
log('info', 'volume-fallback-speech-end', 'Volume fallback detected silence; finalizing recorder segment.', {
level: Number(level.toFixed(1)),
silenceMs: stopDelayMs,
trigger,
})
void stopSegment(trigger)
}
}
volumeFallbackAnimationFrame = requestAnimationFrame(analyze)
}
volumeFallbackAnimationFrame = requestAnimationFrame(analyze)
}
catch (error) {
stopVolumeFallback()
lastError.value = error
log('error', 'volume-fallback-start-failed', 'Failed to start volume-based recorder fallback.', { error })
}
}
async function startAutoSegmentation() {
const stream = mediaRef.value
if (!stream)
throw new Error('No microphone stream available for voice input')
await startVoiceInputVadDetectionSafely({
init: initVAD,
loaded: () => vadLoaded.value,
start: startVAD,
stream,
getError: () => vadError.value,
log,
})
await startVolumeFallback(stream)
}
async function stop(options: { flushActiveRecording?: boolean } = {}) {
stopVolumeFallback()
disposeVAD()
transcriptionChain.reset()
stoppedRecordingSegments.length = 0
if (options.flushActiveRecording && isRecording.value) {
await stopSegment(activeRecordingTrigger.value ?? 'manual')
await transcriptionChain.idle()
transcriptionChain.reset()
}
else if (isRecording.value) {
discardNextRecording = true
try {
await recorder.stopRecord()
}
finally {
discardNextRecording = false
activeRecordingSegment.value = undefined
}
}
else {
activeRecordingSegment.value = undefined
}
}
return {
isRecording,
isTranscribing,
lastTranscriptionText,
lastError,
activeRecordingTrigger,
isSpeechVAD,
isSpeechProb,
isSpeechHistory,
vadLoaded,
vadError,
startSegment,
stopSegment,
startAutoSegmentation,
stop,
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { createVoiceInputTranscriptionChain } from './voice-input-transcription-chain'
describe('createVoiceInputTranscriptionChain', () => {
it('runs queued transcriptions in order while the session stays current', async () => {
const chain = createVoiceInputTranscriptionChain()
const resolved: string[] = []
let finishFirst!: () => void
const first = chain.enqueue(async () => {
await new Promise<void>((resolve) => {
finishFirst = resolve
})
resolved.push('first')
})
const second = chain.enqueue(async () => {
resolved.push('second')
})
await Promise.resolve()
await Promise.resolve()
expect(resolved).toEqual([])
finishFirst()
await first
await second
expect(resolved).toEqual(['first', 'second'])
})
it('lets fresh transcriptions start after reset without waiting for stale provider work', async () => {
const chain = createVoiceInputTranscriptionChain()
const resolved: string[] = []
void chain.enqueue(async () => {
await new Promise<void>(() => {})
resolved.push('stale')
})
await Promise.resolve()
chain.reset()
await chain.enqueue(async (ticket) => {
expect(ticket.isCurrent()).toBe(true)
resolved.push('fresh')
})
expect(resolved).toEqual(['fresh'])
})
it('marks running tickets stale after reset so late results cannot publish', async () => {
const chain = createVoiceInputTranscriptionChain()
let finishFirst!: () => void
let firstTicketStillCurrent = true
const first = chain.enqueue(async (ticket) => {
await new Promise<void>((resolve) => {
finishFirst = resolve
})
firstTicketStillCurrent = ticket.isCurrent()
})
await Promise.resolve()
chain.reset()
finishFirst()
await first
expect(firstTicketStillCurrent).toBe(false)
})
})
@@ -0,0 +1,54 @@
export interface VoiceInputTranscriptionTicket {
/** Returns whether this queued transcription still belongs to the active listening session. */
isCurrent: () => boolean
}
export interface VoiceInputTranscriptionChain {
/** Runs work after earlier current transcription tasks have settled. */
enqueue: <T>(task: (ticket: VoiceInputTranscriptionTicket) => Promise<T> | T) => Promise<T | undefined>
/** Invalidates pending/running tickets and lets future work start from a fresh tail. */
reset: () => void
/** Resolves when all currently chained transcription work has settled. */
idle: () => Promise<void>
}
export function createVoiceInputTranscriptionChain(): VoiceInputTranscriptionChain {
let tail = Promise.resolve()
let generation = 0
function enqueue<T>(task: (ticket: VoiceInputTranscriptionTicket) => Promise<T> | T) {
const taskGeneration = generation
const ticket: VoiceInputTranscriptionTicket = {
isCurrent: () => taskGeneration === generation,
}
const run = tail.then(async () => {
if (!ticket.isCurrent())
return undefined
return task(ticket)
})
tail = run.then(
() => undefined,
() => undefined,
)
return run
}
function reset() {
generation += 1
tail = Promise.resolve()
}
function idle() {
return tail
}
return {
enqueue,
reset,
idle,
}
}
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { startVoiceInputVadDetectionSafely } from './voice-input-vad-startup'
describe('voice input VAD startup', () => {
it('returns false and logs when VAD initialization throws', async () => {
const init = vi.fn().mockRejectedValue(new Error('vad unavailable'))
const start = vi.fn()
const log = vi.fn()
await expect(startVoiceInputVadDetectionSafely({
init,
loaded: () => false,
start,
stream: {} as MediaStream,
log,
})).resolves.toBe(false)
expect(start).not.toHaveBeenCalled()
expect(log).toHaveBeenCalledWith(
'error',
'vad-init-failed',
'VAD initialization failed.',
expect.objectContaining({
error: expect.any(Error),
}),
)
})
})
@@ -0,0 +1,38 @@
import type { VoiceInputSessionLogLevel } from './voice-input-session'
export interface VoiceInputVadStartupOptions {
init: () => Promise<void>
loaded: () => boolean
start: (stream: MediaStream) => Promise<void>
stream: MediaStream
getError?: () => unknown
log?: (level: VoiceInputSessionLogLevel, event: string, message: string, details?: Record<string, unknown>) => void
}
export async function startVoiceInputVadDetectionSafely(options: VoiceInputVadStartupOptions) {
try {
await options.init()
if (options.loaded()) {
options.log?.('info', 'vad-start', 'VAD initialized successfully; starting against microphone stream.', {
stream: options.stream,
})
await options.start(options.stream)
return true
}
const error = options.getError?.()
if (error) {
options.log?.('error', 'vad-init-failed', 'VAD initialization failed.', {
error,
})
}
}
catch (error) {
options.log?.('error', 'vad-init-failed', 'VAD initialization failed.', {
error,
})
}
return false
}
@@ -0,0 +1,111 @@
import type { BaseVAD } from './vad'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createVADStates } from './vad'
class FakeAudioNode {
connect = vi.fn()
disconnect = vi.fn()
port = { onmessage: null as ((event: MessageEvent) => void) | null }
}
class FakeAudioContext {
state: AudioContextState = 'running'
destination = new FakeAudioNode()
audioWorklet = {
addModule: vi.fn(async () => {}),
}
createMediaStreamSource = vi.fn(() => new FakeAudioNode())
createGain = vi.fn(() => ({
gain: { value: 1 },
connect: vi.fn(),
disconnect: vi.fn(),
}))
async resume() {
this.state = 'running'
}
suspend = vi.fn(async () => {
this.state = 'suspended'
})
close = vi.fn(async () => {
this.state = 'closed'
})
}
class FakeAudioWorkletNode extends FakeAudioNode {
constructor() {
super()
}
}
function createVADMock(): BaseVAD {
return {
initialize: vi.fn(async () => {}),
processAudio: vi.fn(async () => {}),
on: vi.fn(),
off: vi.fn(),
}
}
describe('createVADStates', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not stop the caller-owned microphone stream when disposing VAD nodes', async () => {
// NOTICE:
// Vitest node tests do not provide Web Audio constructors.
// The regression is about our ownership policy around a caller-owned MediaStream, not browser audio.
// Source/context: packages/stage-ui/src/libs/audio/vad.ts dispose previously called track.stop().
// Removal condition: replace this with a browser-mode Web Audio lifecycle test.
vi.stubGlobal('AudioContext', FakeAudioContext)
vi.stubGlobal('AudioWorkletNode', FakeAudioWorkletNode)
const stop = vi.fn()
const stream = {
getTracks: () => [{ stop }],
} as unknown as MediaStream
const manager = createVADStates(createVADMock(), '/vad-worklet.js')
await manager.initialize()
await manager.start(stream)
manager.dispose()
expect(stop).not.toHaveBeenCalled()
})
it('disconnects the previous microphone source before starting a new graph', async () => {
// NOTICE:
// The page can call start from both the init continuation and the stream/loaded watcher.
// This fake Web Audio graph keeps the regression focused on duplicate source-node wiring.
// Source/context: apps/stage-tamagotchi/src/renderer/pages/index.vue can restart VAD around stream changes.
// Removal condition: replace this with browser-mode Web Audio graph lifecycle coverage.
const createdSources: FakeAudioNode[] = []
class ReconnectAudioContext extends FakeAudioContext {
createMediaStreamSource = vi.fn(() => {
const source = new FakeAudioNode()
createdSources.push(source)
return source
})
}
vi.stubGlobal('AudioContext', ReconnectAudioContext)
vi.stubGlobal('AudioWorkletNode', FakeAudioWorkletNode)
const stream = {
getTracks: () => [],
} as unknown as MediaStream
const manager = createVADStates(createVADMock(), '/vad-worklet.js')
await manager.initialize()
await manager.start(stream)
await manager.start(stream)
expect(createdSources).toHaveLength(2)
expect(createdSources[0].disconnect).toHaveBeenCalledTimes(1)
expect(createdSources[1].disconnect).not.toHaveBeenCalled()
})
})
+23 -11
View File
@@ -60,6 +60,7 @@ export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, option
let audioWorkletNode: AudioWorkletNode | null
let mediaStream: MediaStream | null
let sourceNode: MediaStreamAudioSourceNode | null
let silentGainNode: GainNode | null
let workletInitialized: boolean
const {
@@ -96,6 +97,20 @@ export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, option
}
}
/**
* Disconnects caller-owned microphone graph nodes before rebuilding the input graph.
*/
function disconnectInputGraph() {
if (sourceNode) {
sourceNode.disconnect()
sourceNode = null
}
if (silentGainNode) {
silentGainNode.disconnect()
silentGainNode = null
}
}
async function start(stream: MediaStream) {
if (!audioContext || !audioWorkletNode) {
throw new Error('Audio system not initialized. Call initialize() first.')
@@ -107,6 +122,7 @@ export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, option
}
// Request microphone access
disconnectInputGraph()
mediaStream = stream
// Create source node and connect to worklet
@@ -115,10 +131,10 @@ export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, option
// Connect worklet to a silent destination (to keep the audio graph active)
// Using a GainNode with gain=0 to ensure no sound is output
const silentGain = audioContext.createGain()
silentGain.gain.value = 0
audioWorkletNode.connect(silentGain)
silentGain.connect(audioContext.destination)
silentGainNode = audioContext.createGain()
silentGainNode.gain.value = 0
audioWorkletNode.connect(silentGainNode)
silentGainNode.connect(audioContext.destination)
}
catch (error) {
console.error('Failed to start microphone:', error)
@@ -133,18 +149,14 @@ export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, option
}
function dispose() {
if (sourceNode) {
sourceNode.disconnect()
sourceNode = null
}
disconnectInputGraph()
if (audioWorkletNode) {
audioWorkletNode.disconnect()
audioWorkletNode = null
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
// The MediaStream is owned by the caller (settings audio device store). VAD only borrows it
// to build an AudioNode graph, so disposing VAD must not stop the microphone device itself.
mediaStream = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
@@ -17,11 +17,17 @@ export function withCredentials() {
const chatSession = getActivePinia() ? useChatSessionStore() : null
if (chatSession?.activeSessionId)
headers.set('x-airi-session-id', chatSession.activeSessionId)
return globalThis.fetch(input, {
const requestInit = {
...init,
headers,
credentials: 'omit',
})
} as RequestInit & { duplex?: 'half' }
if (init?.body instanceof ReadableStream)
requestInit.duplex = 'half'
return globalThis.fetch(input, requestInit)
}
}
@@ -0,0 +1,89 @@
import type {
PlaybackEndEvent,
PlaybackInterruptEvent,
PlaybackItem,
PlaybackRejectEvent,
PlaybackStartEvent,
} from '@proj-airi/pipelines-audio'
import { describe, expect, it } from 'vitest'
import { bindSpeakingStateToPlaybackManager } from './playback-speaking-state'
function createPlaybackItem(): PlaybackItem<AudioBuffer> {
return {
id: 'playback-1',
streamId: 'stream-1',
intentId: 'intent-1',
segmentId: 'segment-1',
sequence: 1,
priority: 0,
text: 'hello',
special: null,
audio: {} as AudioBuffer,
createdAt: 1000,
}
}
function createFakePlaybackManager() {
const listeners = {
start: [] as Array<(event: PlaybackStartEvent<AudioBuffer>) => void>,
end: [] as Array<(event: PlaybackEndEvent<AudioBuffer>) => void>,
interrupt: [] as Array<(event: PlaybackInterruptEvent<AudioBuffer>) => void>,
reject: [] as Array<(event: PlaybackRejectEvent<AudioBuffer>) => void>,
}
return {
listeners,
manager: {
onStart: (listener: (event: PlaybackStartEvent<AudioBuffer>) => void) => {
listeners.start.push(listener)
},
onEnd: (listener: (event: PlaybackEndEvent<AudioBuffer>) => void) => {
listeners.end.push(listener)
},
onInterrupt: (listener: (event: PlaybackInterruptEvent<AudioBuffer>) => void) => {
listeners.interrupt.push(listener)
},
onReject: (listener: (event: PlaybackRejectEvent<AudioBuffer>) => void) => {
listeners.reject.push(listener)
},
},
}
}
describe('bindSpeakingStateToPlaybackManager', () => {
it('resets speaking state when playback is interrupted', () => {
const playback = createFakePlaybackManager()
let speaking = false
bindSpeakingStateToPlaybackManager(playback.manager, {
setSpeaking: (value) => {
speaking = value
},
})
const item = createPlaybackItem()
playback.listeners.start.forEach(listener => listener({ item, startedAt: 1000 }))
expect(speaking).toBe(true)
playback.listeners.interrupt.forEach(listener => listener({ item, reason: 'playback-error', interruptedAt: 1100 }))
expect(speaking).toBe(false)
})
it('resets speaking state when playback is rejected before it can finish', () => {
const playback = createFakePlaybackManager()
let speaking = true
bindSpeakingStateToPlaybackManager(playback.manager, {
setSpeaking: (value) => {
speaking = value
},
})
const item = createPlaybackItem()
playback.listeners.reject.forEach(listener => listener({ item, reason: 'overflow' }))
expect(speaking).toBe(false)
})
})
@@ -0,0 +1,53 @@
import type {
PlaybackEndEvent,
PlaybackInterruptEvent,
PlaybackRejectEvent,
PlaybackStartEvent,
} from '@proj-airi/pipelines-audio'
export interface PlaybackSpeakingStateManager<TAudio> {
onStart: (listener: (event: PlaybackStartEvent<TAudio>) => void) => void
onEnd: (listener: (event: PlaybackEndEvent<TAudio>) => void) => void
onInterrupt: (listener: (event: PlaybackInterruptEvent<TAudio>) => void) => void
onReject: (listener: (event: PlaybackRejectEvent<TAudio>) => void) => void
}
export interface PlaybackSpeakingStateHandlers<TAudio> {
setSpeaking: (value: boolean) => void
onStart?: (event: PlaybackStartEvent<TAudio>) => void
}
/**
* Binds assistant speaking state to every terminal playback outcome.
*
* Use when:
* - UI state must show whether assistant audio is currently audible.
* - Voice input should be suspended only while playback is actually active.
*
* Expects:
* - Playback managers emit exactly one terminal event for each accepted item.
*
* Returns:
* - Nothing; listeners are registered on the provided manager.
*/
export function bindSpeakingStateToPlaybackManager<TAudio>(
manager: PlaybackSpeakingStateManager<TAudio>,
handlers: PlaybackSpeakingStateHandlers<TAudio>,
) {
manager.onStart((event) => {
handlers.setSpeaking(true)
handlers.onStart?.(event)
})
manager.onEnd(() => {
handlers.setSpeaking(false)
})
manager.onInterrupt(() => {
handlers.setSpeaking(false)
})
manager.onReject(() => {
handlers.setSpeaking(false)
})
}
@@ -5,17 +5,21 @@ import { resolveVADConfig } from './vad'
describe('resolveVADConfig', () => {
it('uses safer defaults for threshold and silence duration', () => {
expect(resolveVADConfig()).toEqual({
speechThreshold: 0.6,
exitThreshold: 0.18,
minSilenceDurationMs: 800,
speechThreshold: 0.52,
exitThreshold: 0.156,
minSilenceDurationMs: 1200,
speechPadMs: 360,
minSpeechDurationMs: 300,
})
})
it('preserves explicit threshold and silence duration values', () => {
expect(resolveVADConfig(0.45, 650)).toEqual({
expect(resolveVADConfig(0.45, 650, 420, 500)).toEqual({
speechThreshold: 0.45,
exitThreshold: 0.135,
minSilenceDurationMs: 650,
speechPadMs: 420,
minSpeechDurationMs: 500,
})
})
})
+43 -4
View File
@@ -11,21 +11,33 @@ import { createVAD, createVADStates } from '../../../workers/vad'
interface UseVADOptions {
threshold?: MaybeRefOrGetter<number>
minSilenceDurationMs?: MaybeRefOrGetter<number>
speechPadMs?: MaybeRefOrGetter<number>
minSpeechDurationMs?: MaybeRefOrGetter<number>
onSpeechStart?: () => void
onSpeechEnd?: () => void
onSpeechReady?: (event: { buffer: Float32Array, duration: number }) => void
}
const DEFAULT_VAD_THRESHOLD = 0.6
const DEFAULT_VAD_MIN_SILENCE_DURATION_MS = 800
const DEFAULT_VAD_THRESHOLD = 0.52
const DEFAULT_VAD_MIN_SILENCE_DURATION_MS = 1200
const DEFAULT_VAD_SPEECH_PAD_MS = 360
const DEFAULT_VAD_MIN_SPEECH_DURATION_MS = 300
export function resolveVADConfig(threshold?: number, minSilenceDurationMs?: number): Pick<BaseVADConfig, 'speechThreshold' | 'exitThreshold' | 'minSilenceDurationMs'> {
export function resolveVADConfig(
threshold?: number,
minSilenceDurationMs?: number,
speechPadMs?: number,
minSpeechDurationMs?: number,
): Pick<BaseVADConfig, 'speechThreshold' | 'exitThreshold' | 'minSilenceDurationMs' | 'speechPadMs' | 'minSpeechDurationMs'> {
const resolvedThreshold = threshold ?? DEFAULT_VAD_THRESHOLD
return {
speechThreshold: resolvedThreshold,
exitThreshold: resolvedThreshold * 0.3,
minSilenceDurationMs: minSilenceDurationMs ?? DEFAULT_VAD_MIN_SILENCE_DURATION_MS,
speechPadMs: speechPadMs ?? DEFAULT_VAD_SPEECH_PAD_MS,
minSpeechDurationMs: minSpeechDurationMs ?? DEFAULT_VAD_MIN_SPEECH_DURATION_MS,
}
}
@@ -33,6 +45,8 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
const defaultOptions: UseVADOptions = {
threshold: ref(DEFAULT_VAD_THRESHOLD),
minSilenceDurationMs: ref(DEFAULT_VAD_MIN_SILENCE_DURATION_MS),
speechPadMs: ref(DEFAULT_VAD_SPEECH_PAD_MS),
minSpeechDurationMs: ref(DEFAULT_VAD_MIN_SPEECH_DURATION_MS),
}
options = merge(defaultOptions, options)
@@ -51,6 +65,8 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
const threshold = toRef(options.threshold)
const minSilenceDurationMs = toRef(options.minSilenceDurationMs)
const speechPadMs = toRef(options.speechPadMs)
const minSpeechDurationMs = toRef(options.minSpeechDurationMs)
async function init() {
if (loaded.value || loading.value || manager.value)
@@ -60,7 +76,12 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
inferenceError.value = ''
try {
const vadConfig = resolveVADConfig(threshold.value, minSilenceDurationMs.value)
const vadConfig = resolveVADConfig(
threshold.value,
minSilenceDurationMs.value,
speechPadMs.value,
minSpeechDurationMs.value,
)
vad.value = await createVAD({
sampleRate: 16000,
@@ -78,6 +99,10 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
options?.onSpeechEnd?.()
})
vad.value.on('speech-ready', (event) => {
options?.onSpeechReady?.(event)
})
vad.value.on('debug', ({ data }) => {
if (data?.probability !== undefined) {
isSpeechProb.value = data.probability
@@ -149,6 +174,18 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
}
})
watch(speechPadMs, (newVal) => {
if (vad.value && newVal !== undefined) {
vad.value.updateConfig({ speechPadMs: newVal })
}
})
watch(minSpeechDurationMs, (newVal) => {
if (vad.value && newVal !== undefined) {
vad.value.updateConfig({ minSpeechDurationMs: newVal })
}
})
return {
isSpeech,
isSpeechProb,
@@ -158,6 +195,8 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
inferenceError,
threshold,
minSilenceDurationMs,
speechPadMs,
minSpeechDurationMs,
init,
start,
@@ -0,0 +1,71 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const analyticsMock = vi.hoisted(() => ({
allowComposableCall: true,
trackSttFailed: vi.fn(),
trackSttStarted: vi.fn(),
trackSttSucceeded: vi.fn(),
}))
vi.mock('../../composables/use-analytics', () => ({
useAnalytics: () => {
if (!analyticsMock.allowComposableCall)
throw new Error('Must be called at the top of a `setup` function')
return {
trackSttFailed: analyticsMock.trackSttFailed,
trackSttStarted: analyticsMock.trackSttStarted,
trackSttSucceeded: analyticsMock.trackSttSucceeded,
}
},
}))
vi.mock('@xsai/generate-transcription', () => ({
generateTranscription: vi.fn(async () => ({ text: 'hello' })),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: { value: 'en' },
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}))
describe('useHearingStore analytics lifecycle', () => {
beforeEach(() => {
setActivePinia(createPinia())
analyticsMock.allowComposableCall = true
analyticsMock.trackSttFailed.mockReset()
analyticsMock.trackSttStarted.mockReset()
analyticsMock.trackSttSucceeded.mockReset()
})
/**
* @example
* await hearingStore.transcription(providerId, provider, model, file)
*/
it('does not call analytics composables when a recording is transcribed later', async () => {
const { useHearingStore } = await import('./hearing')
const hearingStore = useHearingStore()
analyticsMock.allowComposableCall = false
const result = await hearingStore.transcription(
'openai-compatible-audio-transcription',
{
transcription: () => ({}),
} as any,
'FunAudioLLM/SenseVoiceSmall',
new File(['hello'], 'recording.wav', { type: 'audio/wav' }),
)
expect(result.text).toBe('hello')
expect(analyticsMock.trackSttStarted).toHaveBeenCalledWith('openai-compatible-audio-transcription')
expect(analyticsMock.trackSttSucceeded).toHaveBeenCalledWith({
provider: 'openai-compatible-audio-transcription',
latency_ms: expect.any(Number),
char_count: 5,
stream: false,
})
}, 10000)
})
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest'
import { filterTranscriptionByConfidence, resolveStreamTranscriptionExecutor } from './hearing'
import {
describeEmptyTranscriptionResponse,
filterTranscriptionByConfidence,
normalizeGeneratedTranscriptionText,
resolveActiveTranscriptionModel,
resolveActiveTranscriptionProviderError,
resolveStreamTranscriptionExecutor,
resolveTranscriptionFileName,
resolveTranscriptionProviderOptions,
} from './hearing'
describe('filterTranscriptionByConfidence', () => {
const segments = [
@@ -41,3 +50,111 @@ describe('resolveStreamTranscriptionExecutor', () => {
expect(executor).toBe(resolveStreamTranscriptionExecutor('aliyun-nls-transcription'))
})
})
describe('resolveActiveTranscriptionProviderError', () => {
/**
* @example
* resolveActiveTranscriptionProviderError('')
*/
it('returns a clear setup error when no transcription provider is selected', () => {
expect(resolveActiveTranscriptionProviderError('')).toBe('No active transcription provider selected. Select a provider in Settings > Hearing.')
})
/**
* @example
* resolveActiveTranscriptionProviderError('openai-compatible-audio-transcription')
*/
it('allows a selected transcription provider', () => {
expect(resolveActiveTranscriptionProviderError('openai-compatible-audio-transcription')).toBeUndefined()
})
})
describe('resolveActiveTranscriptionModel', () => {
/**
* @example
* resolveActiveTranscriptionModel('', { model: 'FunAudioLLM/SenseVoiceSmall' })
*/
it('uses the provider config model when the hearing model has not been synced', () => {
expect(resolveActiveTranscriptionModel('', { model: 'FunAudioLLM/SenseVoiceSmall' })).toBe('FunAudioLLM/SenseVoiceSmall')
})
/**
* @example
* resolveActiveTranscriptionModel('whisper-1', { model: 'FunAudioLLM/SenseVoiceSmall' })
*/
it('prefers the explicit hearing model over the provider config model', () => {
expect(resolveActiveTranscriptionModel('whisper-1', { model: 'FunAudioLLM/SenseVoiceSmall' })).toBe('whisper-1')
})
})
describe('resolveTranscriptionProviderOptions', () => {
/**
* @example
* resolveTranscriptionProviderOptions({}, 'zh-Hans')
*/
it('derives a two-letter transcription language from the active UI locale', () => {
expect(resolveTranscriptionProviderOptions({}, 'zh-Hans')).toEqual({ language: 'zh' })
})
/**
* @example
* resolveTranscriptionProviderOptions({ language: 'ja' }, 'zh-Hans')
*/
it('prefers the provider language when one is configured explicitly', () => {
expect(resolveTranscriptionProviderOptions({ language: 'ja' }, 'zh-Hans')).toEqual({ language: 'ja' })
})
})
describe('normalizeGeneratedTranscriptionText', () => {
/**
* @example
* normalizeGeneratedTranscriptionText({ result: { text: '你好' } })
*/
it('reads nested text from OpenAI-compatible provider variants', () => {
expect(normalizeGeneratedTranscriptionText({ result: { text: '你好' } })).toBe('你好')
})
/**
* @example
* normalizeGeneratedTranscriptionText({ segments: [{ text: '你' }, { text: '好' }] })
*/
it('joins segment text when no top-level text is returned', () => {
expect(normalizeGeneratedTranscriptionText({ segments: [{ text: '你' }, { text: '好' }] })).toBe('你好')
})
/**
* @example
* normalizeGeneratedTranscriptionText({ segments: [{ text: ' Hello' }, { text: ' world' }] })
*/
it('preserves segment whitespace before trimming the final fallback text', () => {
expect(normalizeGeneratedTranscriptionText({ segments: [{ text: ' Hello' }, { text: ' world' }] })).toBe('Hello world')
})
/**
* @example
* normalizeGeneratedTranscriptionText({ data: { text: '你好' } })
*/
it('reads data text from provider envelope responses', () => {
expect(normalizeGeneratedTranscriptionText({ data: { text: '你好' } })).toBe('你好')
})
})
describe('describeEmptyTranscriptionResponse', () => {
/**
* @example
* describeEmptyTranscriptionResponse({ result: { duration: 1 } })
*/
it('describes response keys when no usable text was returned', () => {
expect(describeEmptyTranscriptionResponse({ result: { duration: 1 } })).toContain('keys=result')
})
})
describe('resolveTranscriptionFileName', () => {
/**
* @example
* resolveTranscriptionFileName(new File([], 'recording.wav'))
*/
it('uses the File name so OpenAI-compatible providers can infer the audio format', () => {
expect(resolveTranscriptionFileName(new File([], 'recording.wav'))).toBe('recording.wav')
})
})
+239 -20
View File
@@ -74,6 +74,7 @@ export type HearingTranscriptionResult = HearingTranscriptionGenerateResult | He
type HearingTranscriptionInput = File | {
file?: File
fileName?: string
inputAudioStream?: ReadableStream<ArrayBuffer>
}
@@ -94,6 +95,111 @@ export function filterTranscriptionByConfidence(
return segments.filter(s => (s?.avg_logprob ?? -Infinity) >= threshold).map(s => s?.text ?? '').join('').trim()
}
/**
* Reads a string field from an unknown response object.
*/
function stringField(value: unknown, key: string, options: { trim?: boolean } = {}) {
if (!value || typeof value !== 'object')
return ''
const field = (value as Record<string, unknown>)[key]
if (typeof field !== 'string')
return ''
return options.trim === false ? field : field.trim()
}
/**
* Reads a nested object field from an unknown response object.
*/
function objectField(value: unknown, key: string) {
if (!value || typeof value !== 'object')
return undefined
const field = (value as Record<string, unknown>)[key]
return field && typeof field === 'object' ? field : undefined
}
/**
* Normalizes generated transcription text from OpenAI-compatible response variants.
*
* Before:
* - `{ result: { text: "你好" } }`
* - `{ segments: [{ text: "你" }, { text: "好" }] }`
*
* After:
* - `"你好"`
*/
export function normalizeGeneratedTranscriptionText(response: unknown) {
const directText = stringField(response, 'text')
if (directText)
return directText
for (const envelopeKey of ['result', 'data', 'output']) {
const nested = objectField(response, envelopeKey)
const nestedText = stringField(nested, 'text')
if (nestedText)
return nestedText
}
const segments = objectField(response, 'segments') ?? (response && typeof response === 'object' ? (response as Record<string, unknown>).segments : undefined)
if (Array.isArray(segments)) {
const text = segments
.map(segment => stringField(segment, 'text', { trim: false }))
.join('')
.trim()
if (text)
return text
}
return ''
}
/**
* Builds a compact diagnostic summary for an empty transcription response.
*/
export function describeEmptyTranscriptionResponse(response: unknown) {
if (!response || typeof response !== 'object')
return `response=${String(response)}`
const keys = Object.keys(response as Record<string, unknown>)
const nestedKeys = keys
.map((key) => {
const nested = objectField(response, key)
return nested ? `${key}.{${Object.keys(nested as Record<string, unknown>).join(',')}}` : ''
})
.filter(Boolean)
return [
`keys=${keys.join(',') || '(none)'}`,
...(nestedKeys.length ? [`nested=${nestedKeys.join(';')}`] : []),
].join(' ')
}
/**
* Resolves the upload filename for transcription requests.
*
* Use when:
* - OpenAI-compatible providers infer audio format from multipart filenames.
*
* Expects:
* - `file.name` may carry the recorder-generated extension.
*
* Returns:
* - A stable filename with an audio extension.
*/
export function resolveTranscriptionFileName(file: File, explicitFileName?: string) {
const explicit = explicitFileName?.trim()
if (explicit)
return explicit
const fileName = file.name.trim()
if (fileName)
return fileName
return 'recording.wav'
}
const STREAM_TRANSCRIPTION_EXECUTORS: Record<string, StreamTranscription> = {
'aliyun-nls-transcription': streamAliyunTranscription,
[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]: streamAliyunTranscription,
@@ -104,9 +210,78 @@ export function resolveStreamTranscriptionExecutor(providerId: string): StreamTr
return STREAM_TRANSCRIPTION_EXECUTORS[providerId]
}
/**
* Resolves the setup error for the selected transcription provider.
*
* Use when:
* - A speech pipeline entry point needs to fail before provider instantiation.
* - User-facing diagnostics should explain the missing Hearing selection.
*
* Expects:
* - `providerId` is the current `settings/hearing/active-provider` value.
*
* Returns:
* - A setup error when no provider is selected, otherwise `undefined`.
*/
export function resolveActiveTranscriptionProviderError(providerId: string): string | undefined {
if (providerId)
return undefined
return 'No active transcription provider selected. Select a provider in Settings > Hearing.'
}
/**
* Resolves the transcription model from Hearing state with provider config fallback.
*
* Use when:
* - OpenAI-compatible transcription stores the model in provider settings.
* - The Hearing module has not yet synchronized that model into its active model state.
*
* Expects:
* - `activeModel` is the current Hearing model value.
* - `providerConfig.model` may contain a provider-scoped model name.
*
* Returns:
* - The explicit Hearing model first, then the provider config model, otherwise an empty string.
*/
export function resolveActiveTranscriptionModel(activeModel: string, providerConfig?: Record<string, unknown>) {
const modelFromHearing = activeModel.trim()
if (modelFromHearing)
return modelFromHearing
const modelFromProviderConfig = typeof providerConfig?.model === 'string' ? providerConfig.model.trim() : ''
return modelFromProviderConfig
}
/**
* Resolves extra transcription request options from provider config and UI locale.
*
* Use when:
* - Short ASR recordings need a language hint to avoid multilingual auto-detection drift.
* - Provider-specific transcription prompts are configured outside the Hearing active model field.
*
* Expects:
* - `uiLocale` uses a BCP-47-like language tag such as `zh-Hans` or `en-US`.
*
* Returns:
* - OpenAI-compatible transcription options that can be merged into the provider request.
*/
export function resolveTranscriptionProviderOptions(providerConfig?: Record<string, unknown>, uiLocale = globalThis.navigator?.language ?? '') {
const configuredLanguage = typeof providerConfig?.language === 'string' ? providerConfig.language.trim() : ''
const localeLanguage = uiLocale.split(/[-_]/)[0]?.trim().toLowerCase() ?? ''
const language = configuredLanguage || localeLanguage
const prompt = typeof providerConfig?.prompt === 'string' ? providerConfig.prompt.trim() : ''
return {
...(language ? { language } : {}),
...(prompt ? { prompt } : {}),
}
}
export const useHearingStore = defineStore('hearing-store', () => {
const providersStore = useProvidersStore()
const { allAudioTranscriptionProvidersMetadata } = storeToRefs(providersStore)
const { trackSttStarted, trackSttSucceeded, trackSttFailed } = useAnalytics()
// State
const activeTranscriptionProvider = useLocalStorageManualReset('settings/hearing/active-provider', '')
@@ -196,12 +371,12 @@ export const useHearingStore = defineStore('hearing-store', () => {
): Promise<HearingTranscriptionResult> {
const normalizedInput = (input instanceof File ? { file: input } : input ?? {}) as {
file?: File
fileName?: string
inputAudioStream?: ReadableStream<ArrayBuffer>
}
const features = providersStore.getTranscriptionFeatures(providerId)
const streamExecutor = resolveStreamTranscriptionExecutor(providerId)
const { trackSttStarted, trackSttSucceeded, trackSttFailed } = useAnalytics()
const sttStartedAt = performance.now()
trackSttStarted(providerId)
@@ -275,6 +450,7 @@ export const useHearingStore = defineStore('hearing-store', () => {
const response = await generateTranscription({
...provider.transcription(model, options?.providerOptions),
file: normalizedInput.file,
fileName: resolveTranscriptionFileName(normalizedInput.file, normalizedInput.fileName),
responseFormat: useVerboseJson ? 'verbose_json' : format,
})
@@ -295,11 +471,12 @@ export const useHearingStore = defineStore('hearing-store', () => {
}
}
const fallbackText = typeof response.text === 'string' ? response.text : ''
const fallbackText = normalizeGeneratedTranscriptionText(response)
emitSucceeded(fallbackText.length, false)
return {
mode: 'generate',
...response,
text: fallbackText,
}
}
catch (err) {
@@ -355,6 +532,24 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
let asrSpan: Span | undefined
function startStreamingAsrSpan(providerId: string) {
activeTurnSpan.value?.end()
const turnSpan = startSpan(IOSpanNames.InteractionTurn)
activeTurnSpan.value = turnSpan
asrSpan = startSpan(IOSpanNames.SpeechRecognition, turnSpan, {
[IOAttributes.Subsystem]: IOSubsystems.ASR,
[IOAttributes.GenAIRequestModel]: providerId,
})
}
function endStreamingAsrSpan() {
if (!asrSpan)
return
asrSpan.end()
asrSpan = undefined
}
const supportsStreamInput = computed(() => {
const providerId = activeTranscriptionProvider.value
if (!providerId)
@@ -543,14 +738,6 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
onSentenceEnd?: (delta: string) => void
onSpeechEnd?: (text: string) => void
}) {
activeTurnSpan.value?.end()
const turnSpan = startSpan(IOSpanNames.InteractionTurn)
activeTurnSpan.value = turnSpan
asrSpan = startSpan(IOSpanNames.SpeechRecognition, turnSpan, {
[IOAttributes.Subsystem]: IOSubsystems.ASR,
[IOAttributes.GenAIRequestModel]: activeTranscriptionProvider.value ?? '',
})
console.info('[Hearing Pipeline] transcribeForMediaStream called', {
supportsStreamInput: supportsStreamInput.value,
hasStream: !!stream,
@@ -567,9 +754,10 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
try {
const providerId = activeTranscriptionProvider.value
if (!providerId) {
error.value = 'No transcription provider selected'
console.error('[Hearing Pipeline] No transcription provider selected')
const providerError = resolveActiveTranscriptionProviderError(providerId)
if (providerError) {
error.value = providerError
console.error('[Hearing Pipeline]', providerError)
return
}
@@ -619,6 +807,8 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
}
}
startStreamingAsrSpan(providerId)
// Auto-select default model if not selected
if (!activeTranscriptionModel.value) {
// Try to get models for the provider and select the first one
@@ -760,6 +950,8 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
}
}
startStreamingAsrSpan(providerId)
const abortController = new AbortController()
let idleTimer: ReturnType<typeof setTimeout> | undefined
const bumpIdle = () => {
@@ -856,6 +1048,8 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
}
}
catch (err) {
endStreamingAsrSpan()
if (isExpectedStreamStopError(err))
return
@@ -867,34 +1061,59 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
async function transcribeForRecording(recording: Blob | null | undefined) {
error.value = undefined
if (!recording)
if (!recording) {
error.value = 'No recording captured from microphone'
return
}
if (recording.size <= 0) {
error.value = 'Recording captured from microphone is empty'
return
}
try {
if (recording && recording.size > 0) {
const providerId = activeTranscriptionProvider.value
const providerError = resolveActiveTranscriptionProviderError(providerId)
if (providerError) {
error.value = providerError
console.error('[Hearing Pipeline]', providerError)
return
}
const provider = await providersStore.getProviderInstance<TranscriptionProviderWithExtraOptions<string, any>>(providerId)
if (!provider) {
throw new Error('Failed to initialize speech provider')
}
// Get model from configuration or use default
const model = activeTranscriptionModel.value
const providerConfig = providersStore.getProviderConfig(providerId)
const model = resolveActiveTranscriptionModel(activeTranscriptionModel.value, providerConfig)
const providerOptions = resolveTranscriptionProviderOptions(providerConfig)
console.info('[Hearing Pipeline] Transcribing recording', {
providerId,
language: providerOptions.language,
model,
recordingSize: recording.size,
recordingType: recording.type,
})
const result = await hearingStore.transcription(
providerId,
provider,
model,
new File([recording], 'recording.wav'),
new File([recording], 'recording.wav', { type: recording.type || 'audio/wav' }),
undefined,
{ providerOptions },
)
const text = result.mode === 'stream' ? await result.text : result.text
if (!text || !text.trim()) {
error.value = 'No transcription result returned from provider'
const responseSummary = result.mode === 'generate'
? describeEmptyTranscriptionResponse(result)
: 'stream result returned empty text'
error.value = `No transcription result returned from provider (${responseSummary})`
return
}
return text
}
}
catch (err) {
error.value = errorMessage(err)
console.error('Error generating transcription:', error.value)
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { buildOpenAICompatibleProvider } from './openai-compatible-builder'
describe('buildOpenAICompatibleProvider', () => {
/**
* @example
* provider.transcription('FunAudioLLM/SenseVoiceSmall', { language: 'zh' })
*/
it('preserves transcription extra options for OpenAI-compatible ASR providers', async () => {
const metadata = buildOpenAICompatibleProvider({
id: 'test-openai-compatible-transcription',
name: 'Test Transcription',
nameKey: 'test.transcription.title',
description: 'Test transcription provider',
descriptionKey: 'test.transcription.description',
icon: 'i-lobe-icons:openai',
category: 'transcription',
creator: () => ({
transcription: (model: string) => ({
baseURL: 'https://example.com/v1/',
model,
}),
}),
})
const provider = await metadata.createProvider({})
expect('transcription' in provider).toBe(true)
expect((provider as any).transcription('FunAudioLLM/SenseVoiceSmall', { language: 'zh' })).toEqual({
baseURL: 'https://example.com/v1/',
language: 'zh',
model: 'FunAudioLLM/SenseVoiceSmall',
})
})
})
@@ -35,6 +35,26 @@ function logWarn(...args: unknown[]) {
console.warn(...args)
}
/**
* Wraps transcription providers so OpenAI audio options like `language` and `prompt` are preserved.
*/
function withTranscriptionExtraOptions(provider: unknown) {
if (!provider || typeof provider !== 'object' || !('transcription' in provider))
return provider
const transcription = (provider as { transcription?: unknown }).transcription
if (typeof transcription !== 'function')
return provider
return {
...provider,
transcription: (model: string, extraOptions?: Record<string, unknown>) => ({
...transcription(model),
...extraOptions,
}),
}
}
export function buildOpenAICompatibleProvider(
options: Partial<ProviderMetadata> & {
id: string
@@ -270,7 +290,11 @@ export function buildOpenAICompatibleProvider(
createProvider: async (config: { apiKey: string, baseUrl: string }) => {
const apiKey = normalizeString(config.apiKey)
const baseUrl = normalizeBaseUrl(config.baseUrl)
return creator(apiKey, baseUrl)
const provider = await creator(apiKey, baseUrl)
if (resolvedCategory === 'transcription')
return withTranscriptionExtraOptions(provider)
return provider
},
capabilities: finalCapabilities,
validators: finalValidators,
@@ -0,0 +1,136 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const storageMock = vi.hoisted(() => ({
values: new Map<string, unknown>(),
}))
const audioDeviceMock = vi.hoisted(() => ({
audioInputs: { value: [] as MediaDeviceInfo[] },
selectedAudioInput: { value: '' },
startStream: vi.fn(),
stopStream: vi.fn(),
askPermission: vi.fn(),
}))
vi.mock('@proj-airi/stage-shared/composables', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
return {
useLocalStorageManualReset: <T>(key: string, initialValue: T) => {
const value = vue.ref((storageMock.values.has(key) ? storageMock.values.get(key) : initialValue) as T)
storageMock.values.set(key, value.value)
vue.watch(value, (newValue) => {
storageMock.values.set(key, newValue)
}, { flush: 'sync' })
return Object.assign(value, {
reset: () => {
value.value = initialValue
},
})
},
}
})
vi.mock('../../composables/audio', async () => {
const vue = await vi.importActual<typeof import('vue')>('vue')
return {
useAudioDevice: () => ({
audioInputs: audioDeviceMock.audioInputs,
deviceConstraints: vue.computed(() => ({ audio: true })),
selectedAudioInput: audioDeviceMock.selectedAudioInput,
startStream: audioDeviceMock.startStream,
stopStream: audioDeviceMock.stopStream,
stream: vue.shallowRef<MediaStream>(),
askPermission: audioDeviceMock.askPermission,
}),
}
})
function createAudioInput(deviceId: string): MediaDeviceInfo {
return {
deviceId,
groupId: '',
kind: 'audioinput',
label: deviceId,
toJSON: () => ({}),
}
}
describe('store settings-audio-devices', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
storageMock.values.clear()
audioDeviceMock.audioInputs.value = []
audioDeviceMock.selectedAudioInput.value = ''
vi.clearAllMocks()
})
afterEach(() => {
vi.resetModules()
})
it('starts with the persisted microphone instead of overwriting it with the runtime default', async () => {
storageMock.values.set('settings/audio/input', 'microphone-1')
storageMock.values.set('settings/audio/input/enabled', true)
audioDeviceMock.audioInputs.value = [
createAudioInput('default'),
createAudioInput('microphone-1'),
]
audioDeviceMock.selectedAudioInput.value = 'default'
const startedWith: string[] = []
audioDeviceMock.startStream.mockImplementation(async () => {
startedWith.push(audioDeviceMock.selectedAudioInput.value)
})
const { useSettingsAudioDevice } = await import('./audio-device')
const store = useSettingsAudioDevice()
store.initialize()
await Promise.resolve()
expect(startedWith).toEqual(['microphone-1'])
expect(store.selectedAudioInput).toBe('microphone-1')
expect(storageMock.values.get('settings/audio/input')).toBe('microphone-1')
})
it('ignores stale microphone startup failures after a newer start succeeds', async () => {
const { useSettingsAudioDevice } = await import('./audio-device')
const store = useSettingsAudioDevice()
let rejectFirstStart!: (error: unknown) => void
let resolveSecondStart!: () => void
audioDeviceMock.startStream
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
rejectFirstStart = reject
}))
.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveSecondStart = resolve
}))
store.enabled = true
await nextTick()
store.enabled = false
await nextTick()
store.enabled = true
await nextTick()
resolveSecondStart()
await Promise.resolve()
rejectFirstStart(new Error('old startup failed'))
await Promise.resolve()
await nextTick()
expect(store.enabled).toBe(true)
expect(audioDeviceMock.stopStream).toHaveBeenCalledTimes(1)
})
})
@@ -7,10 +7,68 @@ import { useAudioDevice } from '../../composables/audio'
let microphonePermissionStatus: PermissionStatus
export const useSettingsAudioDevice = defineStore('settings-audio-devices', () => {
const { audioInputs, deviceConstraints, selectedAudioInput: selectedAudioInputNonPersist, startStream, stopStream, stream, askPermission } = useAudioDevice()
const {
audioInputs,
deviceConstraints,
selectedAudioInput: selectedAudioInputNonPersist,
startStream: startAudioInputStream,
stopStream: stopAudioInputStream,
stream,
askPermission: askAudioInputPermission,
} = useAudioDevice()
const selectedAudioInputPersist = useLocalStorageManualReset<string>('settings/audio/input', selectedAudioInputNonPersist.value)
const audioInputEnabled = useLocalStorageManualReset<boolean>('settings/audio/input/enabled', false)
let audioInputStartGeneration = 0
function syncSelectedAudioInputFromRuntime() {
if (selectedAudioInputPersist.value !== selectedAudioInputNonPersist.value)
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value
}
function syncSelectedAudioInputToRuntime() {
if (selectedAudioInputPersist.value && selectedAudioInputPersist.value !== selectedAudioInputNonPersist.value)
selectedAudioInputNonPersist.value = selectedAudioInputPersist.value
}
async function askPermission() {
syncSelectedAudioInputToRuntime()
await askAudioInputPermission()
syncSelectedAudioInputFromRuntime()
}
function createAudioInputStartGeneration() {
audioInputStartGeneration += 1
return audioInputStartGeneration
}
function invalidateAudioInputStarts() {
audioInputStartGeneration += 1
}
async function startStreamForGeneration(generation: number) {
syncSelectedAudioInputToRuntime()
await startAudioInputStream()
if (generation === audioInputStartGeneration)
syncSelectedAudioInputFromRuntime()
}
async function startStream() {
await startStreamForGeneration(createAudioInputStartGeneration())
}
function stopStream() {
invalidateAudioInputStarts()
stopAudioInputStream()
}
function handleStartStreamError(generation: number, error: unknown, message: string) {
console.error(message, error)
if (generation === audioInputStartGeneration)
audioInputEnabled.value = false
}
watch(selectedAudioInputPersist, (newValue) => {
selectedAudioInputNonPersist.value = newValue
@@ -18,7 +76,10 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
watch(audioInputEnabled, (val) => {
if (val) {
startStream()
const generation = createAudioInputStartGeneration()
startStreamForGeneration(generation).catch((error) => {
handleStartStreamError(generation, error, 'Unable to start audio input stream:')
})
}
else {
stopStream()
@@ -42,8 +103,17 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
const hasSelectedInput = selectedAudioInputPersist.value
&& audioInputs.value.some(device => device.deviceId === selectedAudioInputPersist.value)
if (hasSelectedInput)
syncSelectedAudioInputToRuntime()
if (audioInputEnabled.value && hasSelectedInput) {
startStream()
const generation = createAudioInputStartGeneration()
startStreamForGeneration(generation).catch((error) => {
handleStartStreamError(generation, error, 'Unable to initialize audio input stream:')
})
}
else if (selectedAudioInputPersist.value && audioInputs.value.length > 0 && !hasSelectedInput) {
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value
}
if (selectedAudioInputNonPersist.value && !audioInputEnabled.value) {
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value