diff --git a/packages/stage-ui/src/libs/providers/stream-transcription/index.test.ts b/packages/stage-ui/src/libs/providers/stream-transcription/index.test.ts index 71859767f..b86f38fc2 100644 --- a/packages/stage-ui/src/libs/providers/stream-transcription/index.test.ts +++ b/packages/stage-ui/src/libs/providers/stream-transcription/index.test.ts @@ -35,6 +35,42 @@ describe('streamTranscription', () => { expect((requestInit as RequestInit & { duplex?: string }).duplex).toBe('half') }) + it('normalizes T-3 ArrayBuffer audio chunks before browser fetch', async () => { + // ROOT CAUSE: + // + // The VAD audio stream emits ArrayBuffer chunks. Chromium rejects these + // chunks in a streaming request body and reports `TypeError: Failed to fetch`. + // Fetch requires each request stream chunk to be a Uint8Array. + let uploadedChunk: unknown + const audioStream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4]).buffer) + controller.close() + }, + }) + + const result = streamTranscription({ + baseURL: 'https://example.invalid/transcription', + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + if (!(init?.body instanceof ReadableStream)) + throw new TypeError('Expected a readable request body.') + + const reader = init.body.getReader() + uploadedChunk = (await reader.read()).value + return new Response(new ReadableStream({ + start(controller) { + controller.close() + }, + })) + }, + inputAudioStream: audioStream, + }) + + await expect(result.text).resolves.toBe('') + expect(uploadedChunk).toBeInstanceOf(Uint8Array) + expect(uploadedChunk).toEqual(new Uint8Array([1, 2, 3, 4])) + }) + it('parses split SSE chunks and joins transcription deltas', async () => { const encoder = new TextEncoder() const responseBody = new ReadableStream({ diff --git a/packages/stage-ui/src/libs/providers/stream-transcription/index.ts b/packages/stage-ui/src/libs/providers/stream-transcription/index.ts index e2cadc47d..ec41ddc7c 100644 --- a/packages/stage-ui/src/libs/providers/stream-transcription/index.ts +++ b/packages/stage-ui/src/libs/providers/stream-transcription/index.ts @@ -42,12 +42,30 @@ function createDeferred() { return { promise, resolve, reject } } -function resolveAudioStream(options: StreamTranscriptionOptions): ReadableStream { +/** + * Normalizes an audio chunk for a streaming fetch body. + * + * @example + * normalizeAudioChunk(new Uint8Array([1, 2]).buffer) + * // => Uint8Array([1, 2]) + */ +function normalizeAudioChunk(chunk: AudioChunk): Uint8Array { + if (ArrayBuffer.isView(chunk)) + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) + + return new Uint8Array(chunk) +} + +function resolveAudioStream(options: StreamTranscriptionOptions): ReadableStream { const stream = options.inputAudioStream ?? options.inputStream ?? options.file?.stream() if (!stream) throw new TypeError('Audio stream or file is required for streaming transcription.') - return stream as ReadableStream + return (stream as ReadableStream).pipeThrough(new TransformStream({ + transform(chunk, controller) { + controller.enqueue(normalizeAudioChunk(chunk)) + }, + })) } function parseSSELine(line: string): AIRIStreamTranscriptionDelta | undefined {