fix(stage-ui): Aliyun NLS streamTranscription doesn't support Blob but over WebSocket

This commit is contained in:
Neko Ayaka
2025-12-20 03:33:24 +08:00
parent 6ea5f365e5
commit b1f475b04a
3 changed files with 160 additions and 10 deletions
@@ -3,9 +3,8 @@ import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/stores/provi
import vadWorkletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { createAliyunNLSProvider } from '@proj-airi/stage-ui/stores/providers/aliyun/stream-transcription'
import { createAliyunNLSProvider, streamAliyunTranscription } from '@proj-airi/stage-ui/stores/providers/aliyun/stream-transcription'
import { Button, FieldInput, FieldSelect } from '@proj-airi/ui'
import { streamTranscription } from '@xsai/stream-transcription'
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
type AliyunRegion
@@ -159,7 +158,7 @@ async function startRecording() {
appendLog('Initializing realtime transcription session')
const transcriptionResult = streamTranscription({
const transcriptionResult = streamAliyunTranscription({
...createAliyunNLSProvider(
credentials.accessKeyId.trim(),
credentials.accessKeySecret.trim(),
@@ -183,9 +182,8 @@ async function startRecording() {
}
},
}),
inputStream: audioStream,
inputAudioStream: audioStream,
} as unknown as Parameters<typeof streamTranscription>[0])
} as unknown as Parameters<typeof streamAliyunTranscription>[0])
transcriptionTextPromise.value = transcriptionResult.text
isTranscribing.value = true
@@ -3,12 +3,12 @@ import type { WithUnknown } from '@xsai/shared'
import type { StreamTranscriptionResult, StreamTranscriptionOptions as XSAIStreamTranscriptionOptions } from '@xsai/stream-transcription'
import { generateTranscription } from '@xsai/generate-transcription'
import { streamTranscription } from '@xsai/stream-transcription'
import { defineStore, storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { createResettableLocalStorage, createResettableRef } from '../../utils/resettable'
import { useProvidersStore } from '../providers'
import { streamAliyunTranscription } from '../providers/aliyun/stream-transcription'
export interface StreamTranscriptionFileInputOptions extends Omit<XSAIStreamTranscriptionOptions, 'file' | 'fileName'> {
file: Blob
@@ -36,7 +36,7 @@ interface HearingTranscriptionInvokeOptions {
}
const STREAM_TRANSCRIPTION_EXECUTORS: Record<string, StreamTranscription> = {
'aliyun-nls-transcription': streamTranscription as StreamTranscription,
'aliyun-nls-transcription': streamAliyunTranscription,
}
export const useHearingStore = defineStore('hearing-store', () => {
@@ -1,5 +1,6 @@
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { StreamTranscriptionDelta } from '@xsai/stream-transcription'
import type { CommonRequestOptions } from '@xsai/shared'
import type { StreamTranscriptionDelta, StreamTranscriptionResult } from '@xsai/stream-transcription'
import type { EventStartTranscription, ServerEvent, ServerEvents } from './'
@@ -40,6 +41,15 @@ export interface AliyunStreamTranscriptionHandle {
close: () => Promise<void>
}
interface AliyunStreamTranscriptionOptions extends AliyunRealtimeSpeechExtraOptions {
baseURL?: CommonRequestOptions['baseURL']
fetch?: CommonRequestOptions['fetch']
headers?: HeadersInit
file?: Blob
fileName?: string
inputStream?: ReadableStream<AudioChunk>
}
function toArrayBuffer(chunk: AudioChunk): ArrayBuffer {
if (chunk instanceof ArrayBuffer)
return chunk
@@ -60,6 +70,67 @@ function encodeSSE(payload: StreamTranscriptionDelta): Uint8Array {
return sseEncoder.encode(`data: ${JSON.stringify(payload)}\n\n`)
}
// NOTICE: Copied/adapted from @xsai/stream-transcription SSE parsing to keep behavior consistent.
// Ref: @xsai/stream-transcription@0.4.0-beta.8 (dist/index.js parseChunk/transformChunk).
function parseSSELine(line: string): StreamTranscriptionDelta | undefined {
if (!line || !line.startsWith('data:'))
return undefined
const content = line.slice('data:'.length)
const data = content.startsWith(' ') ? content.slice(1) : content
if (!data)
return undefined
return JSON.parse(data) as StreamTranscriptionDelta
}
function aliyunChunkTransformer() {
const decoder = new TextDecoder()
let buffer = ''
return new TransformStream<Uint8Array, StreamTranscriptionDelta>({
transform: (chunk, controller) => {
buffer += decoder.decode(chunk, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const parsed = parseSSELine(line)
if (parsed)
controller.enqueue(parsed)
}
},
flush: (controller) => {
if (!buffer)
return
const parsed = parseSSELine(buffer)
if (parsed)
controller.enqueue(parsed)
},
})
}
// NOTICE: Copied/adapted from @xsai/stream-transcription delayed promise helper.
// Ref: @xsai/stream-transcription@0.4.0-beta.8 (dist/index.js DelayedPromise usage).
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function resolveAudioStream(options: AliyunStreamTranscriptionOptions): ReadableStream<AudioChunk> {
const stream = options.inputAudioStream ?? options.inputStream ?? options.file?.stream()
if (!stream)
throw new TypeError('Audio stream or file is required for Aliyun streaming transcription.')
return stream as ReadableStream<AudioChunk>
}
interface InternalRealtimeOptions extends CreateAliyunStreamTranscriptionOptions {
onSentenceFinal?: (payload: ServerEvents['SentenceEnd']) => Promise<void> | void
}
@@ -114,8 +185,8 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<A
if (websocket) {
if (websocket.readyState === WebSocket.OPEN) {
mayThrow(() => session.stop(websocket))
websocket.close(1000, 'client closed')
mayThrow(() => session.stop(websocket))
websocket.close(1000, 'client closed')
}
else {
mayThrow(() => websocket?.close())
@@ -198,6 +269,87 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<A
return handle
}
export function streamAliyunTranscription(options: AliyunStreamTranscriptionOptions): StreamTranscriptionResult {
const audioStream = resolveAudioStream(options)
const fetcher = options.fetch ?? globalThis.fetch
const deferredText = createDeferred<string>()
let text = ''
let textStreamCtrl: ReadableStreamDefaultController<string> | undefined
let fullStreamCtrl: ReadableStreamDefaultController<StreamTranscriptionDelta> | undefined
const fullStream = new ReadableStream<StreamTranscriptionDelta>({
start(controller) {
fullStreamCtrl = controller
},
})
const textStream = new ReadableStream<string>({
start(controller) {
textStreamCtrl = controller
},
})
const doStream = async () => {
const requestTarget = options.baseURL instanceof URL
? options.baseURL
: new URL(typeof options.baseURL === 'string' ? options.baseURL : 'http://localhost')
const response = await fetcher(requestTarget, {
body: audioStream,
headers: options.headers,
method: 'POST',
signal: options.abortSignal,
})
if (!response.ok)
throw new Error(`Aliyun streaming transcription request failed with status ${response.status}`)
if (!response.body)
throw new Error('Streaming transcription response is missing a readable body.')
await response.body
.pipeThrough(aliyunChunkTransformer())
.pipeTo(new WritableStream<StreamTranscriptionDelta>({
write: (chunk) => {
fullStreamCtrl?.enqueue(chunk)
if (chunk.type === 'transcript.text.delta') {
text += chunk.delta
textStreamCtrl?.enqueue(chunk.delta)
}
},
close: () => {
fullStreamCtrl?.close()
textStreamCtrl?.close()
},
abort: (reason) => {
fullStreamCtrl?.error(reason)
textStreamCtrl?.error(reason)
},
}))
}
void (async () => {
try {
await doStream()
deferredText.resolve(text)
}
catch (error) {
fullStreamCtrl?.error(error)
textStreamCtrl?.error(error)
deferredText.reject(error)
}
})()
// REVIEW: We mirrored the streaming orchestration from @xsai/stream-transcription instead of
// patching the upstream package because Aliyun uses a custom websocket+fetch bridge (no FormData).
// Keeping it local avoids diverging from the published package while we wait for upstream support.
return {
fullStream,
text: deferredText.promise,
textStream,
}
}
export function createAliyunNLSProvider(
accessKeyId: string,
accessKeySecret: string,