Skip to content

Commit 2b2cb6d

Browse files
feat(stt): add energy-based VAD with silence gate and auto-stop (#249)
* loop: stt-warmup completed after 6 iterations * refactor: extract STT startup helpers and simplify audio recorder * feat(stt): add energy-based VAD with silence gate and auto-stop - New VoiceActivityDetector class: adaptive noise floor, per-frame RMS classification, trailing-silence endpointing, configurable thresholds - AudioWorkletProcessor computes RMS in existing flush loop and posts {samples, rms} instead of a bare Int16Array - AudioRecorder integrates VAD via handleVadFrame() in both worklet and ScriptProcessor paths; auto-calls stop() on trailing silence; silence gate in processRecording() fires onNoSpeech instead of sending empty audio to the transcription API (fixes Whisper hallucination bug) - useSTT wires setOnNoSpeech to reset isProcessing/isRecording cleanly without surfacing an error - computeRms() exported helper for ScriptProcessor fallback path * fix(stt): clear processing state when stopping a silent recording stopRecording() called recorder.stop() before setIsProcessing(true). For a silent take the recorder synchronously fires onNoSpeech (which sets processing false), so the subsequent setIsProcessing(true) won and the voice overlay was stuck in the processing state forever. Set processing before stop() so the recorder callback (onNoSpeech or onDataAvailable) has the final say on the processing flag. * fix(stt): always emit a terminal signal so the voice overlay never sticks Two remaining stuck paths after the silent-stop fix: - processRecording() early-returned without firing onNoSpeech when a take had no captured audio (e.g. a quick start/stop under one worklet flush, totalSamples === 0). Now any non-emitting path (empty audio or no detected speech) fires onNoSpeech, while a genuine abort still returns silently. - stopRecording() set isProcessing true even when the recorder was no longer recording (double-stop or post auto-stop), where stop() skips processRecording entirely and nothing clears the flag. Guard the external path on recorder state before entering processing. * loop: mediarecorder-stt completed after 6 iterations * refactor(stt): extract shared helpers and cleanup * refactor(stt): add separate arm/disarm thresholds for voice swipe * fix(stt): prevent default touch behavior on voice gesture handlers * feat(stt): add audio-level speech detection before emitting data
1 parent ab624a0 commit 2b2cb6d

13 files changed

Lines changed: 1626 additions & 550 deletions

frontend/public/audio-worklet-processor.js

Lines changed: 0 additions & 72 deletions
This file was deleted.

frontend/src/api/stt.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
22
import { sttApi } from './stt'
3+
import { FetchError } from './fetchWrapper'
34

45
describe('WAV extension selection logic', () => {
56
const originalFetch = global.fetch
@@ -69,6 +70,18 @@ describe('WAV extension selection logic', () => {
6970
expect(audioFile.name).toBe('recording.m4a')
7071
})
7172

73+
it('should return webm for audio/webm;codecs=opus blob', async () => {
74+
const blob = new Blob([], { type: 'audio/webm;codecs=opus' })
75+
mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' }))
76+
77+
await sttApi.transcribe(blob, 'test-user')
78+
79+
const callArgs = mockFetch.mock.calls[0]
80+
const formData = callArgs[1]?.body as FormData
81+
const audioFile = formData.get('audio') as File
82+
expect(audioFile.name).toBe('recording.webm')
83+
})
84+
7285
it('should default to wav for unknown types', async () => {
7386
const blob = new Blob([], { type: 'audio/unknown' })
7487
mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' }))
@@ -123,3 +136,85 @@ describe('WAV extension selection logic', () => {
123136
expect(formData.get('audio')).toBeInstanceOf(File)
124137
})
125138
})
139+
140+
describe('sttApi.transcribe cancellation and timeout', () => {
141+
const originalFetch = global.fetch
142+
143+
beforeEach(() => {
144+
vi.stubGlobal('fetch', vi.fn())
145+
})
146+
147+
afterEach(() => {
148+
vi.restoreAllMocks()
149+
global.fetch = originalFetch
150+
})
151+
152+
it('throws 499 CANCELED when caller aborts', async () => {
153+
const blob = new Blob([], { type: 'audio/webm;codecs=opus' })
154+
const abortController = new AbortController()
155+
156+
const mockFetch = global.fetch as ReturnType<typeof vi.fn>
157+
mockFetch.mockImplementation((_url: string, options: RequestInit) => {
158+
const signal = options.signal as AbortSignal
159+
return new Promise((_resolve, reject) => {
160+
if (signal.aborted) {
161+
const err = new Error('The operation was aborted')
162+
err.name = 'AbortError'
163+
reject(err)
164+
return
165+
}
166+
signal.addEventListener('abort', () => {
167+
const err = new Error('The operation was aborted')
168+
err.name = 'AbortError'
169+
reject(err)
170+
}, { once: true })
171+
})
172+
})
173+
174+
const promise = sttApi.transcribe(blob, 'test-user', abortController.signal)
175+
176+
abortController.abort()
177+
178+
await expect(promise).rejects.toThrow(FetchError)
179+
await expect(promise).rejects.toMatchObject({
180+
statusCode: 499,
181+
code: 'CANCELED',
182+
})
183+
})
184+
185+
it('throws 408 TIMEOUT when transcription times out', async () => {
186+
vi.useFakeTimers()
187+
188+
const blob = new Blob([], { type: 'audio/webm;codecs=opus' })
189+
190+
const mockFetch = global.fetch as ReturnType<typeof vi.fn>
191+
mockFetch.mockImplementation((_url: string, options: RequestInit) => {
192+
const signal = options.signal as AbortSignal
193+
return new Promise((_resolve, reject) => {
194+
if (signal.aborted) {
195+
const err = new Error('The operation was aborted')
196+
err.name = 'AbortError'
197+
reject(err)
198+
return
199+
}
200+
signal.addEventListener('abort', () => {
201+
const err = new Error('The operation was aborted')
202+
err.name = 'AbortError'
203+
reject(err)
204+
}, { once: true })
205+
})
206+
})
207+
208+
const promise = sttApi.transcribe(blob, 'test-user')
209+
210+
vi.advanceTimersByTime(60000)
211+
212+
await expect(promise).rejects.toThrow(FetchError)
213+
await expect(promise).rejects.toMatchObject({
214+
statusCode: 408,
215+
code: 'TIMEOUT',
216+
})
217+
218+
vi.useRealTimers()
219+
})
220+
})

frontend/src/api/stt.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,27 @@ export const sttApi = {
5454
urlObj.searchParams.set('userId', userId)
5555

5656
const controller = new AbortController()
57-
const timeoutId = setTimeout(() => controller.abort(), 60000)
57+
let timeoutFired = false
58+
const timeoutId = setTimeout(() => {
59+
timeoutFired = true
60+
controller.abort()
61+
}, 60000)
5862

59-
const abortSignal = signal || controller.signal
63+
if (signal?.aborted) {
64+
controller.abort()
65+
}
66+
const onAbort = () => controller.abort()
67+
signal?.addEventListener('abort', onAbort, { once: true })
6068

6169
try {
6270
const response = await fetch(urlObj.toString(), {
6371
method: 'POST',
6472
body: formData,
65-
signal: abortSignal,
73+
signal: controller.signal,
6674
})
6775

6876
clearTimeout(timeoutId)
77+
signal?.removeEventListener('abort', onAbort)
6978

7079
if (!response.ok) {
7180
const data = await response.json().catch(() => ({ error: 'Transcription failed' }))
@@ -75,7 +84,12 @@ export const sttApi = {
7584
return response.json()
7685
} catch (error) {
7786
clearTimeout(timeoutId)
87+
signal?.removeEventListener('abort', onAbort)
88+
7889
if (error instanceof Error && error.name === 'AbortError') {
90+
if (signal?.aborted && !timeoutFired) {
91+
throw new FetchError('Transcription canceled', 499, 'CANCELED')
92+
}
7993
throw new FetchError('Transcription timeout', 408, 'TIMEOUT')
8094
}
8195
throw error

frontend/src/components/message/PromptInput.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const revokeBlobUrls = (attachments: ImageAttachment[]) => {
4747

4848
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
4949

50-
const VOICE_SEND_SWIPE_THRESHOLD = 30
50+
const VOICE_SEND_SWIPE_ARM_THRESHOLD = 24
51+
const VOICE_SEND_SWIPE_DISARM_THRESHOLD = 8
5152
type VoiceButtonVariant = 'desktop' | 'mobile'
5253

5354

@@ -496,6 +497,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
496497
return
497498
}
498499

500+
event.preventDefault()
499501
voiceGestureStartYRef.current = event.clientY
500502
voiceSwipeArmedRef.current = false
501503
setIsVoiceSwipeArmed(false)
@@ -510,8 +512,11 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
510512
return
511513
}
512514

515+
event.preventDefault()
513516
const deltaY = voiceGestureStartYRef.current - event.clientY
514-
const nextIsSwipeArmed = deltaY >= VOICE_SEND_SWIPE_THRESHOLD
517+
const nextIsSwipeArmed = voiceSwipeArmedRef.current
518+
? deltaY >= VOICE_SEND_SWIPE_DISARM_THRESHOLD
519+
: deltaY >= VOICE_SEND_SWIPE_ARM_THRESHOLD
515520

516521
if (nextIsSwipeArmed !== voiceSwipeArmedRef.current) {
517522
voiceSwipeArmedRef.current = nextIsSwipeArmed
@@ -532,6 +537,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
532537
return
533538
}
534539

540+
event.preventDefault()
535541
voiceGestureStartYRef.current = null
536542

537543
if (canceled || !voiceSwipeArmedRef.current) {
@@ -1096,14 +1102,17 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
10961102
: 'bg-muted hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground border-border active:bg-muted-foreground/30 active:scale-95'
10971103
}`
10981104

1099-
const containerClassName = isDesktop ? 'relative hidden md:block' : 'relative flex w-full'
1105+
const containerClassName = isDesktop ? 'relative hidden md:block' : 'relative flex w-full touch-none select-none'
11001106

11011107
return (
11021108
<div
11031109
ref={voiceButtonContainerRef}
11041110
className={containerClassName}
11051111
{...voiceGestureHandlers}
11061112
>
1113+
{!isDesktop && showVoiceFeedback && (
1114+
<div aria-hidden="true" className="absolute inset-x-0 bottom-full z-20 h-44 touch-none" />
1115+
)}
11071116
<VoiceStatusOverlay show={!isDesktop && showVoiceFeedback} label={voiceFeedbackLabel} state={voiceFeedbackState} />
11081117
<button
11091118
type="button"

frontend/src/components/message/VoiceStatusOverlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function VoiceStatusOverlay({ show, label, state }: VoiceStatusOverlayPro
4848
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
4949
>
5050
<span className="sr-only">{label}</span>
51-
<div className="relative flex h-36 w-full flex-col items-center justify-between overflow-hidden rounded-xl border border-green-300/70 bg-gradient-to-t from-green-700 via-green-500 to-emerald-400 px-1 py-3 text-white shadow-lg shadow-green-500/40">
51+
<div className="animate-voice-overlay-in relative flex h-44 w-full flex-col items-center justify-between overflow-hidden rounded-xl border border-green-300/70 bg-gradient-to-t from-green-700 via-green-500 to-emerald-400 px-1 py-4 text-white shadow-lg shadow-green-500/40">
5252
<div className="absolute inset-x-1 top-1 h-10 rounded-full bg-white/20 blur-sm" />
5353
<div className="relative flex flex-1 flex-col items-center justify-center gap-1">
5454
{isLoading ? (

frontend/src/components/settings/STTSettings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export function STTSettings() {
127127
} else if (sttError) {
128128
setTestResult('failed')
129129
setIsTesting(false)
130+
} else {
131+
setTestResult('idle')
132+
setIsTesting(false)
130133
}
131134
}
132135
}, [isTesting, isRecording, isProcessing, transcript, sttError])

0 commit comments

Comments
 (0)