fix(stage-ui): normalize streaming transcription audio chunks (#2285)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user