fix(stage-ui): normalize streaming transcription audio chunks (#2285)

This commit is contained in:
RainbowBird
2026-08-15 19:37:45 +08:00
committed by GitHub
parent 3da1ce163c
commit 44cd0cfe68
2 changed files with 56 additions and 2 deletions
@@ -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<ArrayBuffer>({
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<Uint8Array>({
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<Uint8Array>({
@@ -42,12 +42,30 @@ function createDeferred<T>() {
return { promise, resolve, reject }
}
function resolveAudioStream(options: StreamTranscriptionOptions): ReadableStream<AudioChunk> {
/**
* 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<Uint8Array> {
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<AudioChunk>
return (stream as ReadableStream<AudioChunk>).pipeThrough(new TransformStream<AudioChunk, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(normalizeAudioChunk(chunk))
},
}))
}
function parseSSELine(line: string): AIRIStreamTranscriptionDelta | undefined {