Skip to content

Commit d48bc4d

Browse files
loop: mediarecorder-stt completed after 6 iterations
1 parent 7961268 commit d48bc4d

11 files changed

Lines changed: 1236 additions & 900 deletions

frontend/public/audio-worklet-processor.js

Lines changed: 0 additions & 76 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

0 commit comments

Comments
 (0)