Skip to content

Commit 8db1617

Browse files
refactor(stt): extract shared helpers and cleanup
1 parent d48bc4d commit 8db1617

9 files changed

Lines changed: 79 additions & 189 deletions

File tree

docs/features/stt.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,6 @@ Any OpenAI-compatible transcription API works:
6969
- Self-hosted Whisper servers
7070
- Local STT servers with OpenAI-compatible API
7171

72-
### Performance
73-
74-
When external voice input is enabled, the browser audio pipeline is warmed up ahead of time so the first and subsequent recordings start faster. The audio context and worklet processor are prepared without requesting microphone access, and are retained between recordings; only the microphone track is stopped after each use. Resources are released entirely when external STT is disabled or the voice input UI unmounts.
75-
76-
This optimization applies only to the external API provider. It does not affect the initial permission prompt — the browser still asks for microphone access on the first recording.
77-
7872
## Using Voice Input
7973

8074
### Tap-to-Start / Tap-to-Stop

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])

frontend/src/hooks/useSTT.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ vi.mock('@/lib/audioRecorder', () => ({
3030
AudioRecorder: mocks.AudioRecorder,
3131
}))
3232

33-
vi.mock('@/lib/webSpeechRecognizer', () => ({
34-
getWebSpeechRecognizer: mocks.getWebSpeechRecognizer,
35-
isWebRecognitionSupported: mocks.isWebRecognitionSupported,
36-
}))
33+
vi.mock('@/lib/webSpeechRecognizer', async (importOriginal) => {
34+
const actual = await importOriginal<typeof import('@/lib/webSpeechRecognizer')>()
35+
return {
36+
appendTranscriptSegment: actual.appendTranscriptSegment,
37+
getWebSpeechRecognizer: mocks.getWebSpeechRecognizer,
38+
isWebRecognitionSupported: mocks.isWebRecognitionSupported,
39+
}
40+
})
3741

3842
vi.mock('@/api/stt', () => ({
3943
sttApi: mocks.sttApi,

frontend/src/hooks/useSTT.ts

Lines changed: 35 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect, useRef, useCallback } from 'react'
22
import { useSettings } from '@/hooks/useSettings'
3-
import { getWebSpeechRecognizer, isWebRecognitionSupported, type SpeechRecognitionOptions, type SpeechRecognitionResult, type RecognitionState } from '@/lib/webSpeechRecognizer'
3+
import { getWebSpeechRecognizer, isWebRecognitionSupported, appendTranscriptSegment, type SpeechRecognitionOptions, type SpeechRecognitionResult, type RecognitionState } from '@/lib/webSpeechRecognizer'
44
import { AudioRecorder } from '@/lib/audioRecorder'
55
import { sttApi } from '@/api/stt'
66
import { FetchError } from '@/api/fetchWrapper'
@@ -42,6 +42,24 @@ export function useSTT(userId = 'default') {
4242
? AudioRecorder.isSupported()
4343
: isWebRecognitionSupported()
4444

45+
const flashError = useCallback((message: string) => {
46+
setIsError(true)
47+
setError(message)
48+
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
49+
errorTimeoutRef.current = setTimeout(() => {
50+
setIsError(false)
51+
setError(null)
52+
errorTimeoutRef.current = null
53+
}, 3000)
54+
}, [])
55+
56+
const resetToIdle = useCallback((clearInterim = false) => {
57+
setIsRecording(false)
58+
setIsProcessing(false)
59+
setState('idle')
60+
if (clearInterim) setInterimTranscript('')
61+
}, [])
62+
4563
useEffect(() => {
4664
if (!isEnabled || isExternalProvider) {
4765
return
@@ -55,11 +73,7 @@ export function useSTT(userId = 'default') {
5573

5674
rec.onResult((result: SpeechRecognitionResult) => {
5775
setIsProcessing(false)
58-
setTranscript((prev) => {
59-
const prevTrimmed = prev.trim()
60-
const next = result.transcript.trim()
61-
return prevTrimmed ? `${prevTrimmed} ${next}` : next
62-
})
76+
setTranscript((prev) => appendTranscriptSegment(prev, result.transcript))
6377
})
6478

6579
rec.onInterimResult((interim: string) => {
@@ -74,21 +88,11 @@ export function useSTT(userId = 'default') {
7488
rec.onError((errorMessage: string) => {
7589
setIsProcessing(false)
7690
setIsRecording(false)
77-
setIsError(true)
78-
setError(errorMessage)
79-
80-
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
81-
errorTimeoutRef.current = setTimeout(() => {
82-
setIsError(false)
83-
setError(null)
84-
errorTimeoutRef.current = null
85-
}, 3000)
91+
flashError(errorMessage)
8692
})
8793

8894
rec.onEnd(() => {
89-
setIsRecording(false)
90-
setIsProcessing(false)
91-
setState('idle')
95+
resetToIdle()
9296
})
9397

9498
rec.onStart(() => {
@@ -105,7 +109,7 @@ export function useSTT(userId = 'default') {
105109
interimRafRef.current = null
106110
}
107111
}
108-
}, [isEnabled, isExternalProvider])
112+
}, [isEnabled, isExternalProvider, flashError, resetToIdle])
109113

110114
const setupAudioRecorder = useCallback((recorder: AudioRecorder) => {
111115
recorder.setOnStateChange((recState) => {
@@ -117,9 +121,7 @@ export function useSTT(userId = 'default') {
117121
} else if (recState === 'stopped') {
118122
setIsRecording(false)
119123
} else if (recState === 'error') {
120-
setIsRecording(false)
121-
setIsProcessing(false)
122-
setState('idle')
124+
resetToIdle()
123125
} else if (recState === 'idle') {
124126
setState('idle')
125127
}
@@ -128,22 +130,11 @@ export function useSTT(userId = 'default') {
128130
recorder.setOnError((errorMessage) => {
129131
setIsProcessing(false)
130132
setIsRecording(false)
131-
setIsError(true)
132-
setError(errorMessage)
133-
134-
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
135-
errorTimeoutRef.current = setTimeout(() => {
136-
setIsError(false)
137-
setError(null)
138-
errorTimeoutRef.current = null
139-
}, 3000)
133+
flashError(errorMessage)
140134
})
141135

142136
recorder.setOnNoSpeech(() => {
143-
setIsProcessing(false)
144-
setIsRecording(false)
145-
setInterimTranscript('')
146-
setState('idle')
137+
resetToIdle(true)
147138
})
148139

149140
recorder.setOnDataAvailable(async (blob) => {
@@ -163,11 +154,7 @@ export function useSTT(userId = 'default') {
163154
abortControllerRef.current.signal
164155
)
165156

166-
setTranscript((prev) => {
167-
const prevTrimmed = prev.trim()
168-
const newText = result.text.trim()
169-
return prevTrimmed ? `${prevTrimmed} ${newText}` : newText
170-
})
157+
setTranscript((prev) => appendTranscriptSegment(prev, result.text))
171158
setInterimTranscript('')
172159
} catch (err) {
173160
if (err instanceof Error && (
@@ -177,23 +164,14 @@ export function useSTT(userId = 'default') {
177164
return
178165
}
179166

180-
setIsError(true)
181-
const errorMessage = err instanceof Error ? err.message : 'Transcription failed'
182-
setError(errorMessage)
183-
184-
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
185-
errorTimeoutRef.current = setTimeout(() => {
186-
setIsError(false)
187-
setError(null)
188-
errorTimeoutRef.current = null
189-
}, 3000)
167+
flashError(err instanceof Error ? err.message : 'Transcription failed')
190168
} finally {
191169
setIsProcessing(false)
192170
setState('idle')
193171
abortControllerRef.current = null
194172
}
195173
})
196-
}, [])
174+
}, [flashError, resetToIdle])
197175

198176
const ensureAudioRecorder = useCallback((): AudioRecorder => {
199177
if (!audioRecorder.current) {
@@ -233,12 +211,10 @@ export function useSTT(userId = 'default') {
233211
} else {
234212
recognizer.current.abort()
235213
}
236-
setIsRecording(false)
237-
setIsProcessing(false)
238-
setState('idle')
214+
resetToIdle()
239215
setIsError(true)
240216
setError('Microphone start timed out')
241-
}, [isExternalProvider, disposeAudioRecorder])
217+
}, [isExternalProvider, disposeAudioRecorder, resetToIdle])
242218

243219
const runStartupWithTimeout = useCallback(
244220
async (startup: () => Promise<void>, startOpId: number): Promise<boolean> => {
@@ -340,21 +316,15 @@ export function useSTT(userId = 'default') {
340316
}
341317

342318
setTranscript('')
343-
setInterimTranscript('')
344-
setIsRecording(false)
345-
setIsProcessing(false)
346-
setState('idle')
347-
}, [isExternalProvider])
319+
resetToIdle(true)
320+
}, [isExternalProvider, resetToIdle])
348321

349322
const reset = useCallback(() => {
350323
setTranscript('')
351-
setInterimTranscript('')
352324
setIsError(false)
353325
setError(null)
354-
setIsRecording(false)
355-
setIsProcessing(false)
356-
setState('idle')
357-
}, [])
326+
resetToIdle(true)
327+
}, [resetToIdle])
358328

359329
const clear = useCallback(() => {
360330
setTranscript('')

frontend/src/index.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ body {
128128
animation: page-slide-in-from-left 300ms cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
129129
}
130130

131+
@keyframes voice-overlay-in {
132+
from { transform: translateY(12px) scale(0.97); opacity: 0; }
133+
to { transform: translateY(0) scale(1); opacity: 1; }
134+
}
135+
136+
.animate-voice-overlay-in {
137+
animation: voice-overlay-in 180ms cubic-bezier(0.2, 0.9, 0.3, 1);
138+
}
139+
131140
@keyframes shine-loading {
132141
0% {
133142
background-position: -200% 0;

frontend/src/lib/audioRecorder.test.ts

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
22
import { AudioRecorder } from './audioRecorder'
33

4-
// ── Fake MediaRecorder ─────────────────────────────────────────────────────
5-
64
class FakeMediaRecorder {
75
static _instances: FakeMediaRecorder[] = []
86
static _supportedTypes = new Set<string>([
@@ -37,8 +35,6 @@ class FakeMediaRecorder {
3735
}
3836
}
3937

40-
// ── Helpers ─────────────────────────────────────────────────────────────────
41-
4238
function createMockTrack(): MediaStreamTrack {
4339
return { stop: vi.fn(), kind: 'audio' } as unknown as MediaStreamTrack
4440
}
@@ -59,8 +55,6 @@ function createMockStream(tracks: MediaStreamTrack[] = [createMockTrack()]): Med
5955
} as unknown as MediaStream
6056
}
6157

62-
// ── describe: AudioRecorder.isSupported ─────────────────────────────────────
63-
6458
describe('AudioRecorder.isSupported', () => {
6559
afterEach(() => {
6660
vi.restoreAllMocks()
@@ -109,8 +103,6 @@ describe('AudioRecorder.isSupported', () => {
109103
})
110104
})
111105

112-
// ── Describe: AudioRecorder start ───────────────────────────────────────────
113-
114106
describe('AudioRecorder start', () => {
115107
let mockGetUserMedia: ReturnType<typeof vi.fn>
116108
let mockTrack: ReturnType<typeof createMockTrack>
@@ -189,15 +181,6 @@ describe('AudioRecorder start', () => {
189181
expect(mr.mimeType).toBe('')
190182
})
191183

192-
it('uses configured mimeTypes when provided', async () => {
193-
recorder = new AudioRecorder({ mimeTypes: ['audio/wav', 'audio/ogg'] })
194-
recorder.setOnStateChange(onStateChange)
195-
FakeMediaRecorder._supportedTypes = new Set(['audio/wav', 'audio/ogg'])
196-
await recorder.start()
197-
const mr = FakeMediaRecorder._instances[0]
198-
expect(mr.mimeType).toBe('audio/wav')
199-
})
200-
201184
it('transitions to recording state', async () => {
202185
await recorder.start()
203186
expect(recorder.getState()).toBe('recording')
@@ -206,8 +189,6 @@ describe('AudioRecorder start', () => {
206189

207190
})
208191

209-
// ── describe: AudioRecorder stop ────────────────────────────────────────────
210-
211192
describe('AudioRecorder stop', () => {
212193
let mockGetUserMedia: ReturnType<typeof vi.fn>
213194
let mockTrack: ReturnType<typeof createMockTrack>
@@ -307,8 +288,6 @@ describe('AudioRecorder stop', () => {
307288
})
308289
})
309290

310-
// ── describe: AudioRecorder no-speech ───────────────────────────────────────
311-
312291
describe('AudioRecorder no-speech', () => {
313292
let mockGetUserMedia: ReturnType<typeof vi.fn>
314293
let mockTrack: ReturnType<typeof createMockTrack>
@@ -366,8 +345,6 @@ describe('AudioRecorder no-speech', () => {
366345
})
367346
})
368347

369-
// ── describe: AudioRecorder abort and dispose ───────────────────────────────
370-
371348
describe('AudioRecorder abort and dispose', () => {
372349
let mockGetUserMedia: ReturnType<typeof vi.fn>
373350
let mockTrack: ReturnType<typeof createMockTrack>
@@ -431,8 +408,6 @@ describe('AudioRecorder abort and dispose', () => {
431408
})
432409
})
433410

434-
// ── describe: AudioRecorder error handling ──────────────────────────────────
435-
436411
describe('AudioRecorder error handling', () => {
437412
let recorder: AudioRecorder
438413
let onError: ReturnType<typeof vi.fn>
@@ -504,41 +479,3 @@ describe('AudioRecorder error handling', () => {
504479
expect(onStateChange).toHaveBeenCalledWith('error')
505480
})
506481
})
507-
508-
// ── describe: AudioRecorder.prepare ─────────────────────────────────────────
509-
510-
describe('AudioRecorder.prepare', () => {
511-
afterEach(() => {
512-
vi.restoreAllMocks()
513-
})
514-
515-
it('resolves when supported', async () => {
516-
Object.defineProperty(navigator, 'mediaDevices', {
517-
value: { getUserMedia: vi.fn() },
518-
writable: true,
519-
configurable: true,
520-
})
521-
Object.defineProperty(window, 'MediaRecorder', {
522-
value: FakeMediaRecorder,
523-
writable: true,
524-
configurable: true,
525-
})
526-
const recorder = new AudioRecorder()
527-
await expect(recorder.prepare()).resolves.toBeUndefined()
528-
})
529-
530-
it('throws when not supported', async () => {
531-
Object.defineProperty(navigator, 'mediaDevices', {
532-
value: {},
533-
writable: true,
534-
configurable: true,
535-
})
536-
Object.defineProperty(window, 'MediaRecorder', {
537-
value: undefined,
538-
writable: true,
539-
configurable: true,
540-
})
541-
const recorder = new AudioRecorder()
542-
await expect(recorder.prepare()).rejects.toThrow('Audio recording is not supported in this browser')
543-
})
544-
})

0 commit comments

Comments
 (0)