mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
refactor(stage-web,stage-pages,stage-ui): better structure for asr & vad
This commit is contained in:
@@ -45,7 +45,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const { stream, enabled } = storeToRefs(settingsAudioDeviceStore)
|
||||
const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { transcribeForRecording } = hearingPipeline
|
||||
const { stopStreamingTranscription, transcribeForMediaStream, transcribeForRecording } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProviderStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
@@ -67,28 +67,39 @@ const {
|
||||
|
||||
let stopOnStopRecord: (() => void) | undefined
|
||||
|
||||
async function sendVoiceInputTextToChat(text: string | undefined) {
|
||||
if (!text?.trim())
|
||||
return
|
||||
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to send chat from voice:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioInteraction() {
|
||||
try {
|
||||
await initVAD()
|
||||
if (stream.value)
|
||||
await startVAD(stream.value)
|
||||
|
||||
if (shouldUseStreamInput.value && stream.value) {
|
||||
await transcribeForMediaStream(stream.value, {
|
||||
onSentenceEnd: text => void sendVoiceInputTextToChat(text),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Hook once
|
||||
stopOnStopRecord = onStopRecord(async (recording) => {
|
||||
const text = await transcribeForRecording(recording)
|
||||
if (!text || !text.trim())
|
||||
return
|
||||
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
}
|
||||
await sendVoiceInputTextToChat(text)
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
@@ -119,6 +130,7 @@ function stopAudioInteraction() {
|
||||
try {
|
||||
stopOnStopRecord?.()
|
||||
stopOnStopRecord = undefined
|
||||
void stopStreamingTranscription(true)
|
||||
disposeVAD()
|
||||
}
|
||||
catch {}
|
||||
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/stores/providers/aliyun'
|
||||
import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/libs/providers/providers/aliyun-nls'
|
||||
|
||||
import vadWorkletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
|
||||
|
||||
import { errorMessageFromValue } from '@proj-airi/stage-shared'
|
||||
import { createAliyunNLSProvider, streamAliyunTranscription } from '@proj-airi/stage-ui/stores/providers/aliyun/stream-transcription'
|
||||
import { createAliyunNLSProvider } from '@proj-airi/stage-ui/libs/providers/providers/aliyun-nls'
|
||||
import { streamTranscription } from '@proj-airi/stage-ui/libs/providers/stream-transcription'
|
||||
import { Button, FieldCombobox, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
@@ -159,7 +160,7 @@ async function startRecording() {
|
||||
|
||||
appendLog('Initializing realtime transcription session')
|
||||
|
||||
const transcriptionResult = streamAliyunTranscription({
|
||||
const transcriptionResult = streamTranscription({
|
||||
...createAliyunNLSProvider(
|
||||
credentials.accessKeyId.trim(),
|
||||
credentials.accessKeySecret.trim(),
|
||||
@@ -184,7 +185,7 @@ async function startRecording() {
|
||||
},
|
||||
}),
|
||||
inputAudioStream: audioStream,
|
||||
} as unknown as Parameters<typeof streamAliyunTranscription>[0])
|
||||
})
|
||||
transcriptionTextPromise.value = transcriptionResult.text
|
||||
isTranscribing.value = true
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/libs/providers/providers/aliyun-nls'
|
||||
import type { HearingTranscriptionResult } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/stores/providers/aliyun'
|
||||
import type { RemovableRef } from '@vueuse/core'
|
||||
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
|
||||
+1
-1
@@ -10,9 +10,9 @@ import {
|
||||
ProviderSettingsLayout,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { selectProviderMetadata } from '@proj-airi/stage-ui/libs'
|
||||
import { streamWebSpeechAPITranscription } from '@proj-airi/stage-ui/libs/providers/providers/browser-web-speech-api'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { streamWebSpeechAPITranscription } from '@proj-airi/stage-ui/stores/providers/web-speech-api'
|
||||
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { Button, FieldCombobox } from '@proj-airi/ui'
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const IOSubsystems = {
|
||||
VAD: 'vad',
|
||||
ASR: 'asr',
|
||||
LLM: 'llm',
|
||||
StreamingControl: 'streaming-control',
|
||||
@@ -9,6 +10,7 @@ export type IOSubsystem = (typeof IOSubsystems)[keyof typeof IOSubsystems]
|
||||
|
||||
export const IOSpanNames = {
|
||||
InteractionTurn: 'Interaction turn',
|
||||
VoiceActivityDetection: 'Voice activity detection',
|
||||
SpeechRecognition: 'Speech recognition',
|
||||
LLMInference: 'LLM inference',
|
||||
StreamingControlDispatch: 'Streaming control dispatch',
|
||||
@@ -26,6 +28,8 @@ export const IOAttributes = {
|
||||
Subsystem: `${customPrefix}.subsystem`,
|
||||
TooltipKeys: `${customPrefix}.tooltip.keys`,
|
||||
LLM_TTFT: `${customPrefix}.llm.time_to_first_token`,
|
||||
VADAudioDurationMs: `${customPrefix}.vad.audio_duration_ms`,
|
||||
VADAborted: `${customPrefix}.vad.aborted`,
|
||||
ASRText: `${customPrefix}.asr.text`,
|
||||
ASRAbort: `${customPrefix}.asr.abort`,
|
||||
LLMTextLength: `${customPrefix}.llm.text_length`,
|
||||
|
||||
@@ -32,11 +32,13 @@
|
||||
"./libs/analytics": "./src/libs/analytics/index.ts",
|
||||
"./libs/analytics/*": "./src/libs/analytics/*.ts",
|
||||
"./libs/pinia": "./src/libs/pinia/index.ts",
|
||||
"./libs/providers/stream-transcription": "./src/libs/providers/stream-transcription/index.ts",
|
||||
"./libs/providers/providers/aliyun-nls": "./src/libs/providers/providers/aliyun-nls/index.ts",
|
||||
"./libs/providers/providers/browser-web-speech-api": "./src/libs/providers/providers/browser-web-speech-api/index.ts",
|
||||
"./libs/*": "./src/libs/*.ts",
|
||||
"./libs": "./src/libs/index.ts",
|
||||
"./services/*": "./src/services/*.ts",
|
||||
"./tools/mcp": "./src/tools/mcp.ts",
|
||||
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
|
||||
"./stores/character": "./src/stores/character/index.ts",
|
||||
"./stores/settings/analytics": "./src/stores/settings/analytics.ts",
|
||||
"./stores/settings": "./src/stores/settings/index.ts",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createVadStreamingSession } from './vad-streaming-session'
|
||||
|
||||
describe('createVadStreamingSession', () => {
|
||||
it('starts one transcription session for a detected speech segment and stops it after silence', async () => {
|
||||
const start = vi.fn(async () => {})
|
||||
const stop = vi.fn(async () => {})
|
||||
const session = createVadStreamingSession({ start, stop })
|
||||
|
||||
session.onSpeechStart()
|
||||
session.onSpeechStart()
|
||||
session.onSpeechEnd()
|
||||
|
||||
await vi.waitFor(() => expect(stop).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops a session when speech ends before its asynchronous start completes', async () => {
|
||||
let releaseStart!: () => void
|
||||
const start = vi.fn(async () => await new Promise<void>((resolve) => {
|
||||
releaseStart = resolve
|
||||
}))
|
||||
const stop = vi.fn(async () => {})
|
||||
const session = createVadStreamingSession({ start, stop })
|
||||
|
||||
session.onSpeechStart()
|
||||
await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(1))
|
||||
session.onSpeechEnd()
|
||||
releaseStart()
|
||||
|
||||
await vi.waitFor(() => expect(stop).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start another session after disposal', async () => {
|
||||
const start = vi.fn(async () => {})
|
||||
const stop = vi.fn(async () => {})
|
||||
const session = createVadStreamingSession({ start, stop })
|
||||
|
||||
await session.dispose()
|
||||
session.onSpeechStart()
|
||||
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
export interface VadStreamingSessionOptions {
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes realtime transcription sessions from VAD speech boundaries.
|
||||
*
|
||||
* A detected speech segment owns one provider session. The session starts when
|
||||
* VAD detects speech and stops after VAD reports the configured silence period.
|
||||
*/
|
||||
export function createVadStreamingSession(options: VadStreamingSessionOptions) {
|
||||
let disposed = false
|
||||
let speechActive = false
|
||||
let providerSessionActive = false
|
||||
let lifecycle = Promise.resolve()
|
||||
|
||||
function enqueue(operation: () => Promise<void>) {
|
||||
lifecycle = lifecycle
|
||||
.catch(() => undefined)
|
||||
.then(operation)
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
function onSpeechStart() {
|
||||
if (disposed || speechActive)
|
||||
return
|
||||
|
||||
speechActive = true
|
||||
void enqueue(async () => {
|
||||
if (disposed || providerSessionActive)
|
||||
return
|
||||
|
||||
try {
|
||||
await options.start()
|
||||
providerSessionActive = true
|
||||
}
|
||||
catch (error) {
|
||||
options.onError?.(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!disposed && speechActive)
|
||||
return
|
||||
|
||||
try {
|
||||
await options.stop()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
finally {
|
||||
providerSessionActive = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onSpeechEnd() {
|
||||
if (disposed || !speechActive)
|
||||
return
|
||||
|
||||
speechActive = false
|
||||
void enqueue(async () => {
|
||||
if (!providerSessionActive)
|
||||
return
|
||||
|
||||
try {
|
||||
await options.stop()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
finally {
|
||||
providerSessionActive = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function dispose() {
|
||||
if (disposed)
|
||||
return
|
||||
|
||||
disposed = true
|
||||
speechActive = false
|
||||
await enqueue(async () => {
|
||||
if (!providerSessionActive)
|
||||
return
|
||||
|
||||
try {
|
||||
await options.stop()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
finally {
|
||||
providerSessionActive = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
onSpeechStart,
|
||||
onSpeechEnd,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface BaseVADConfig {
|
||||
export interface VADEvents {
|
||||
// Emitted when speech is detected
|
||||
'speech-start': void
|
||||
// Emitted with each PCM chunk that belongs to the active speech segment
|
||||
'speech-audio': { buffer: Float32Array }
|
||||
// Emitted when speech has ended
|
||||
'speech-end': void
|
||||
// Emitted when a complete speech segment is ready for transcription
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { AliyunRealtimeSpeechExtraOptions } from '../../../../stores/providers/aliyun/stream-transcription'
|
||||
import type { AliyunRealtimeSpeechExtraOptions } from './provider'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createAliyunNLSProvider } from '../../../../stores/providers/aliyun/stream-transcription'
|
||||
import { defineProvider } from '../registry'
|
||||
import { createAliyunNLSProvider } from './provider'
|
||||
|
||||
const aliyunNlsRegions = [
|
||||
'cn-shanghai',
|
||||
@@ -105,3 +105,7 @@ export const providerAliyunNlsTranscription = defineProvider<AliyunNlsConfig>({
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
export type { AliyunRealtimeSpeechExtraOptions } from './provider'
|
||||
export { createAliyunNLSProvider } from './provider'
|
||||
export type { ServerEvent, ServerEvents } from './session'
|
||||
|
||||
+42
-185
@@ -1,13 +1,11 @@
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
import type { CommonRequestOptions } from '@xsai/shared'
|
||||
import type { StreamTranscriptionDelta, StreamTranscriptionResult } from '@xsai/stream-transcription'
|
||||
|
||||
import type { EventStartTranscription, ServerEvent, ServerEvents } from './'
|
||||
import type { EventStartTranscription, ServerEvent, ServerEvents } from './session'
|
||||
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { timeout as promiseTimeout } from 'es-toolkit/promise'
|
||||
|
||||
import { createAliyunNLSSession } from './'
|
||||
import { createAliyunNLSSession } from './session'
|
||||
import { nlsWebSocketEndpointFromRegion } from './utils'
|
||||
|
||||
type SessionOptions = NonNullable<Parameters<typeof createAliyunNLSSession>[3]>
|
||||
@@ -90,15 +88,6 @@ 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
|
||||
@@ -115,71 +104,10 @@ function toArrayBuffer(chunk: AudioChunk): ArrayBuffer {
|
||||
|
||||
const sseEncoder = new TextEncoder()
|
||||
|
||||
function encodeSSE(payload: StreamTranscriptionDelta): Uint8Array {
|
||||
function encodeSSE(payload: { delta: string, type: 'transcript.text.delta' | 'transcript.text.done' }): 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
|
||||
idleTimeoutMs?: number
|
||||
@@ -219,6 +147,7 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<A
|
||||
|
||||
const stopWaiter = createWaiter(stopAckTimeoutMs, abortSignal)
|
||||
let stopping = false
|
||||
let cleanupPromise: Promise<void> | undefined
|
||||
|
||||
async function requestStop(reason?: unknown) {
|
||||
if (stopping)
|
||||
@@ -252,28 +181,35 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<A
|
||||
|
||||
bumpIdle()
|
||||
|
||||
async function cleanup(error?: unknown, options?: { sendStop?: boolean, closeSocket?: boolean }) {
|
||||
const { sendStop = true, closeSocket = true } = options ?? {}
|
||||
abortHandler?.off()
|
||||
await tryCatch(async () => await reader.cancel())
|
||||
function cleanup(error?: unknown, options?: { sendStop?: boolean, closeSocket?: boolean }) {
|
||||
if (cleanupPromise)
|
||||
return cleanupPromise
|
||||
|
||||
if (websocket && closeSocket) {
|
||||
switch (websocket.readyState) {
|
||||
case WebSocket.OPEN:
|
||||
if (sendStop)
|
||||
await tryCatch(() => session.stop(websocket))
|
||||
websocket.close(1000, 'client closed')
|
||||
break
|
||||
case WebSocket.CONNECTING:
|
||||
websocket.close(1000, 'client closed')
|
||||
break
|
||||
default:
|
||||
// If the server has already initiated closure, avoid sending another close frame.
|
||||
break
|
||||
cleanupPromise = (async () => {
|
||||
const { sendStop = true, closeSocket = true } = options ?? {}
|
||||
abortHandler?.off()
|
||||
await tryCatch(async () => await reader.cancel())
|
||||
|
||||
if (websocket && closeSocket) {
|
||||
switch (websocket.readyState) {
|
||||
case WebSocket.OPEN:
|
||||
if (sendStop)
|
||||
await tryCatch(() => session.stop(websocket))
|
||||
websocket.close(1000, 'client closed')
|
||||
break
|
||||
case WebSocket.CONNECTING:
|
||||
websocket.close(1000, 'client closed')
|
||||
break
|
||||
default:
|
||||
// If the server has already initiated closure, avoid sending another close frame.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSessionTerminated?.(error)
|
||||
await onSessionTerminated?.(error)
|
||||
})()
|
||||
|
||||
return cleanupPromise
|
||||
}
|
||||
|
||||
const handle: AliyunStreamTranscriptionHandle = {
|
||||
@@ -296,8 +232,9 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<A
|
||||
bumpIdle()
|
||||
}
|
||||
|
||||
// Allow a grace period for server to flush final events before stop.
|
||||
bumpIdle()
|
||||
// The VAD-owned audio stream closes only after speech is complete. End
|
||||
// NLS immediately so it returns its final sentence before the next VAD segment.
|
||||
await requestStop()
|
||||
}
|
||||
catch (error) {
|
||||
await cleanup(error)
|
||||
@@ -356,87 +293,6 @@ 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,
|
||||
@@ -469,20 +325,21 @@ export function createAliyunNLSProvider(
|
||||
audioStream: streamSource as ReadableStream<AudioChunk>,
|
||||
abortSignal: extraOptions?.abortSignal || init?.signal || undefined,
|
||||
hooks: extraOptions?.hooks,
|
||||
onSessionTerminated: async (error) => {
|
||||
onSessionTerminated: async (sessionError) => {
|
||||
controllerClosed = true
|
||||
try {
|
||||
await extraOptions?.onSessionTerminated?.(error)
|
||||
await extraOptions?.onSessionTerminated?.(sessionError)
|
||||
if (sessionError) {
|
||||
controller.error(sessionError instanceof Error ? sessionError : new Error(String(sessionError)))
|
||||
return
|
||||
}
|
||||
|
||||
controller.enqueue(encodeSSE({ delta: '', type: 'transcript.text.done' }))
|
||||
controller.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('error in onSessionTerminated hook:', error)
|
||||
}
|
||||
finally {
|
||||
if (error)
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
else
|
||||
controller.close()
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
},
|
||||
onSentenceFinal: async (payload) => {
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createWebSpeechAPIProvider } from '../../../../stores/providers/web-speech-api'
|
||||
import { defineProvider } from '../registry'
|
||||
import { createWebSpeechAPIProvider } from './provider'
|
||||
|
||||
const webSpeechApiConfigSchema = z.object({
|
||||
language: z.string().default('en-US'),
|
||||
@@ -11,6 +11,9 @@ const webSpeechApiConfigSchema = z.object({
|
||||
maxAlternatives: z.number().int().positive().default(1),
|
||||
})
|
||||
|
||||
export type { WebSpeechAPIExtraOptions } from './provider'
|
||||
export { createWebSpeechAPIProvider, streamWebSpeechAPITranscription } from './provider'
|
||||
|
||||
function isWebSpeechApiAvailable() {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { ComposerTranslation } from 'vue-i18n'
|
||||
import { createUnElevenLabs, listVoices } from 'unspeech'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { models as elevenLabsModels } from '../../../../stores/providers/elevenlabs/list-models'
|
||||
import { defineProvider } from '../registry'
|
||||
import { models as elevenLabsModels } from './list-models'
|
||||
|
||||
const elevenLabsConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { streamTranscription } from './index'
|
||||
|
||||
describe('streamTranscription', () => {
|
||||
it('parses split SSE chunks and joins transcription deltas', async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const responseBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"transcript.text.delta","delta":"Hello"}\n'))
|
||||
controller.enqueue(encoder.encode('\ndata: {"type":"transcript.text.delta","delta":" AIRI"}\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const audioStream = new ReadableStream<ArrayBuffer>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
const result = streamTranscription({
|
||||
baseURL: 'https://example.invalid/transcription',
|
||||
fetch: async () => new Response(responseBody),
|
||||
inputAudioStream: audioStream,
|
||||
})
|
||||
|
||||
expect(await result.text).toBe('Hello AIRI')
|
||||
await expect(result.textStream.getReader().read()).resolves.toEqual({ done: false, value: 'Hello' })
|
||||
})
|
||||
|
||||
it('rejects requests without an audio input', () => {
|
||||
expect(() => streamTranscription({})).toThrow('Audio stream or file is required')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { CommonRequestOptions } from '@xsai/shared'
|
||||
import type { StreamTranscriptionDelta, StreamTranscriptionResult } from '@xsai/stream-transcription'
|
||||
|
||||
type AudioChunk = ArrayBuffer | ArrayBufferView
|
||||
|
||||
/** Options for adapting an SSE transcription request to xsAI stream results. */
|
||||
export interface StreamTranscriptionOptions {
|
||||
abortSignal?: AbortSignal
|
||||
baseURL?: CommonRequestOptions['baseURL']
|
||||
fetch?: CommonRequestOptions['fetch']
|
||||
headers?: HeadersInit
|
||||
file?: Blob
|
||||
inputAudioStream?: ReadableStream<AudioChunk>
|
||||
inputStream?: ReadableStream<AudioChunk>
|
||||
}
|
||||
|
||||
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: StreamTranscriptionOptions): ReadableStream<AudioChunk> {
|
||||
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>
|
||||
}
|
||||
|
||||
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 createSSETransformer() {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an SSE transcription endpoint into xsAI's streaming result shape.
|
||||
*
|
||||
* The provider owns transport details. This adapter owns only request input,
|
||||
* SSE parsing, and the result streams consumed by Hearing.
|
||||
*/
|
||||
export function streamTranscription(options: StreamTranscriptionOptions): 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
|
||||
},
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
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(`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(createSSETransformer())
|
||||
.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)
|
||||
},
|
||||
}))
|
||||
deferredText.resolve(text)
|
||||
}
|
||||
catch (error) {
|
||||
fullStreamCtrl?.error(error)
|
||||
textStreamCtrl?.error(error)
|
||||
deferredText.reject(error)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
fullStream,
|
||||
text: deferredText.promise,
|
||||
textStream,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import type { ProviderDefinition } from '../types'
|
||||
|
||||
import { createChatProvider } from '@xsai-ext/providers/utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { validateProvider } from './run'
|
||||
|
||||
const mockT = ((key: string) => key) as unknown as ComposerTranslation
|
||||
|
||||
describe('validateProvider', () => {
|
||||
it('disposes the temporary provider after runtime validation', async () => {
|
||||
const dispose = vi.fn()
|
||||
const provider = Object.assign(
|
||||
createChatProvider({ apiKey: 'test', baseURL: 'https://example.com/v1' }),
|
||||
{ dispose },
|
||||
)
|
||||
const definition: ProviderDefinition<Record<string, unknown>> = {
|
||||
id: 'example',
|
||||
name: 'Example',
|
||||
description: 'Example provider',
|
||||
nameLocalize: input => input.t('example'),
|
||||
descriptionLocalize: input => input.t('example'),
|
||||
tasks: [],
|
||||
createProviderConfig: () => ({}) as never,
|
||||
createProvider: () => provider,
|
||||
}
|
||||
|
||||
await validateProvider({
|
||||
steps: [{ id: 'runtime', label: 'Runtime', status: 'idle', reason: '', kind: 'provider' }],
|
||||
config: {},
|
||||
definition,
|
||||
configValidators: [],
|
||||
providerValidators: [{
|
||||
id: 'runtime',
|
||||
name: 'Runtime',
|
||||
validator: async () => ({ valid: true, errors: [], reason: '', reasonKey: '' }),
|
||||
}],
|
||||
providerExtra: undefined,
|
||||
shouldValidate: true,
|
||||
}, { t: mockT })
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -164,23 +164,28 @@ export async function validateProvider(
|
||||
return steps
|
||||
}
|
||||
|
||||
await Promise.all(providerValidators.map(async (validatorDefinition, index) => {
|
||||
const step = steps[providerStepOffset + index]
|
||||
step.status = 'validating'
|
||||
step.reason = ''
|
||||
onValidatorStart?.({ kind: 'provider', index, step })
|
||||
try {
|
||||
const result = await validatorDefinition.validator(config, providerInstance, providerExtra as any, runContext)
|
||||
step.status = result.valid ? 'valid' : 'invalid'
|
||||
step.reason = result.valid ? '' : result.reason
|
||||
onValidatorSuccess?.({ kind: 'provider', index, step, result })
|
||||
}
|
||||
catch (error) {
|
||||
step.status = 'invalid'
|
||||
step.reason = errorMessageFrom(error) ?? 'Unknown error'
|
||||
onValidatorError?.({ kind: 'provider', index, step, error })
|
||||
}
|
||||
}))
|
||||
try {
|
||||
await Promise.all(providerValidators.map(async (validatorDefinition, index) => {
|
||||
const step = steps[providerStepOffset + index]
|
||||
step.status = 'validating'
|
||||
step.reason = ''
|
||||
onValidatorStart?.({ kind: 'provider', index, step })
|
||||
try {
|
||||
const result = await validatorDefinition.validator(config, providerInstance, providerExtra as any, runContext)
|
||||
step.status = result.valid ? 'valid' : 'invalid'
|
||||
step.reason = result.valid ? '' : result.reason
|
||||
onValidatorSuccess?.({ kind: 'provider', index, step, result })
|
||||
}
|
||||
catch (error) {
|
||||
step.status = 'invalid'
|
||||
step.reason = errorMessageFrom(error) ?? 'Unknown error'
|
||||
onValidatorError?.({ kind: 'provider', index, step, error })
|
||||
}
|
||||
}))
|
||||
}
|
||||
finally {
|
||||
await (providerInstance as ProviderInstance & { dispose?: () => Promise<void> | void }).dispose?.()
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IOAttributes, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveVADConfig } from './vad'
|
||||
|
||||
const vadMocks = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event?: unknown) => void>()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
createVAD: vi.fn(async () => ({
|
||||
on: vi.fn((name: string, handler: (event?: unknown) => void) => handlers.set(name, handler)),
|
||||
updateConfig: vi.fn(),
|
||||
})),
|
||||
initialize: vi.fn(async () => undefined),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const spanMock = vi.hoisted(() => ({
|
||||
end: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
}))
|
||||
|
||||
const startSpanMock = vi.hoisted(() => vi.fn(() => spanMock))
|
||||
|
||||
vi.mock('../../../workers/vad', () => ({
|
||||
createVAD: vadMocks.createVAD,
|
||||
createVADStates: () => ({
|
||||
initialize: vadMocks.initialize,
|
||||
start: vadMocks.start,
|
||||
stop: vadMocks.stop,
|
||||
dispose: vadMocks.dispose,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../composables/use-io-tracer', () => ({
|
||||
startSpan: startSpanMock,
|
||||
}))
|
||||
|
||||
describe('resolveVADConfig', () => {
|
||||
it('uses safer defaults for threshold and silence duration', () => {
|
||||
expect(resolveVADConfig()).toEqual({
|
||||
@@ -23,3 +61,56 @@ describe('resolveVADConfig', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useVAD', () => {
|
||||
beforeEach(() => {
|
||||
vadMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records a completed VAD span for each detected speech segment', async () => {
|
||||
const { useVAD } = await import('./vad')
|
||||
const vad = useVAD('vad-worker-url')
|
||||
|
||||
await vad.init()
|
||||
vadMocks.handlers.get('speech-start')?.()
|
||||
vadMocks.handlers.get('speech-ready')?.({
|
||||
buffer: new Float32Array([0.25, -0.25]),
|
||||
duration: 1500,
|
||||
})
|
||||
|
||||
expect(startSpanMock).toHaveBeenCalledWith(
|
||||
IOSpanNames.VoiceActivityDetection,
|
||||
undefined,
|
||||
{
|
||||
[IOAttributes.Subsystem]: IOSubsystems.VAD,
|
||||
},
|
||||
)
|
||||
expect(spanMock.setAttribute).toHaveBeenCalledWith(IOAttributes.VADAudioDurationMs, 1500)
|
||||
expect(spanMock.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('marks an active VAD span as aborted when the session is disposed', async () => {
|
||||
const { useVAD } = await import('./vad')
|
||||
const vad = useVAD('vad-worker-url')
|
||||
|
||||
await vad.init()
|
||||
vadMocks.handlers.get('speech-start')?.()
|
||||
vad.dispose()
|
||||
|
||||
expect(spanMock.setAttribute).toHaveBeenCalledWith(IOAttributes.VADAborted, true)
|
||||
expect(spanMock.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards VAD-owned PCM chunks while speech is active', async () => {
|
||||
const onSpeechAudio = vi.fn()
|
||||
const { useVAD } = await import('./vad')
|
||||
const vad = useVAD('vad-worker-url', { onSpeechAudio })
|
||||
const buffer = new Float32Array([0.25, -0.25])
|
||||
|
||||
await vad.init()
|
||||
vadMocks.handlers.get('speech-audio')?.({ buffer })
|
||||
|
||||
expect(onSpeechAudio).toHaveBeenCalledWith({ buffer })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Span } from '@opentelemetry/api'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { BaseVADConfig } from '../../../libs/audio/vad'
|
||||
|
||||
import { merge } from '@moeru/std'
|
||||
import { errorMessageFromValue } from '@proj-airi/stage-shared'
|
||||
import { errorMessageFromValue, IOAttributes, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
|
||||
import { ref, toRef, watch } from 'vue'
|
||||
|
||||
import { startSpan } from '../../../composables/use-io-tracer'
|
||||
import { createVAD, createVADStates } from '../../../workers/vad'
|
||||
|
||||
interface UseVADOptions {
|
||||
@@ -15,6 +17,7 @@ interface UseVADOptions {
|
||||
minSpeechDurationMs?: MaybeRefOrGetter<number>
|
||||
|
||||
onSpeechStart?: () => void
|
||||
onSpeechAudio?: (event: { buffer: Float32Array }) => void
|
||||
onSpeechEnd?: () => void
|
||||
onSpeechReady?: (event: { buffer: Float32Array, duration: number }) => void
|
||||
}
|
||||
@@ -62,6 +65,17 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
|
||||
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
let activeSpan: Span | undefined
|
||||
|
||||
function finishActiveSpan(aborted: boolean) {
|
||||
if (!activeSpan)
|
||||
return
|
||||
|
||||
if (aborted)
|
||||
activeSpan.setAttribute(IOAttributes.VADAborted, true)
|
||||
activeSpan.end()
|
||||
activeSpan = undefined
|
||||
}
|
||||
|
||||
const threshold = toRef(options.threshold)
|
||||
const minSilenceDurationMs = toRef(options.minSilenceDurationMs)
|
||||
@@ -90,16 +104,26 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
|
||||
|
||||
// Set up event handlers
|
||||
vad.value.on('speech-start', () => {
|
||||
finishActiveSpan(true)
|
||||
activeSpan = startSpan(IOSpanNames.VoiceActivityDetection, undefined, {
|
||||
[IOAttributes.Subsystem]: IOSubsystems.VAD,
|
||||
})
|
||||
isSpeech.value = true
|
||||
options?.onSpeechStart?.()
|
||||
})
|
||||
|
||||
vad.value.on('speech-audio', (event) => {
|
||||
options?.onSpeechAudio?.(event)
|
||||
})
|
||||
|
||||
vad.value.on('speech-end', () => {
|
||||
isSpeech.value = false
|
||||
options?.onSpeechEnd?.()
|
||||
})
|
||||
|
||||
vad.value.on('speech-ready', (event) => {
|
||||
activeSpan?.setAttribute(IOAttributes.VADAudioDurationMs, event.duration)
|
||||
finishActiveSpan(false)
|
||||
options?.onSpeechReady?.(event)
|
||||
})
|
||||
|
||||
@@ -150,6 +174,7 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
finishActiveSpan(true)
|
||||
manager.value?.stop()
|
||||
manager.value?.dispose()
|
||||
manager.value = undefined
|
||||
|
||||
@@ -15,11 +15,13 @@ import vadWorkletUrl from '../../workers/vad/process.worklet?worker&url'
|
||||
|
||||
import { useAnalytics } from '../../composables/use-analytics'
|
||||
import { activeTurnSpan, startSpan } from '../../composables/use-io-tracer'
|
||||
import { createVadStreamingSession } from '../../libs/audio/vad-streaming-session'
|
||||
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers'
|
||||
import { streamAliyunTranscription } from '../providers/aliyun/stream-transcription'
|
||||
import { streamWebSpeechAPITranscription } from '../../libs/providers/providers/browser-web-speech-api'
|
||||
import { streamTranscription } from '../../libs/providers/stream-transcription'
|
||||
import { useVAD } from '../ai/models/vad'
|
||||
import { useProviderConfigStore } from '../providers/config'
|
||||
import { useProviderStore } from '../providers/provider'
|
||||
import { streamWebSpeechAPITranscription } from '../providers/web-speech-api'
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
const msg = errorMessageFromValue(err)
|
||||
@@ -39,7 +41,7 @@ function errorMessage(err: unknown): string {
|
||||
// an inactive stream. Those cases should not be surfaced as provider failures because the session was
|
||||
// explicitly asked to stop. If a future abort is noisy or unexpected, inspect the abort source first:
|
||||
// `stopStreamingTranscription()` in this file is the primary origin, and provider-specific teardown
|
||||
// bridges such as `packages/stage-ui/src/stores/providers/aliyun/stream-transcription.ts` propagate the
|
||||
// provider adapters in `packages/stage-ui/src/libs/providers/providers/` propagate the
|
||||
// same reason through the transport. Only treat an abort as "expected" if it is one of these known
|
||||
// shutdown paths; any other `AbortError` should still be investigated as a real lifecycle bug or a
|
||||
// provider/runtime failure.
|
||||
@@ -110,6 +112,14 @@ interface HearingTranscriptionInvokeOptions {
|
||||
providerOptions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MediaStreamTranscriptionOptions {
|
||||
sampleRate?: number
|
||||
providerOptions?: Record<string, unknown>
|
||||
idleTimeoutMs?: number
|
||||
onSentenceEnd?: (delta: string) => void
|
||||
onSpeechEnd?: (text: string) => void
|
||||
}
|
||||
|
||||
export const CONFIDENCE_THRESHOLD_DISABLED = -3
|
||||
|
||||
export function filterTranscriptionByConfidence(
|
||||
@@ -229,8 +239,8 @@ export function resolveTranscriptionFileName(file: File, explicitFileName?: stri
|
||||
}
|
||||
|
||||
const STREAM_TRANSCRIPTION_EXECUTORS: Record<string, StreamTranscription> = {
|
||||
'aliyun-nls-transcription': streamAliyunTranscription,
|
||||
[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]: streamAliyunTranscription,
|
||||
'aliyun-nls-transcription': streamTranscription,
|
||||
[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]: streamTranscription,
|
||||
// Web Speech API is handled specially in transcribeForMediaStream since it works directly with MediaStream
|
||||
}
|
||||
|
||||
@@ -572,9 +582,9 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
trackVoiceInputStarted,
|
||||
} = useAnalytics()
|
||||
const streamingSession = shallowRef<{
|
||||
audioContext: AudioContext | Record<string, never>
|
||||
workletNode: AudioWorkletNode | Record<string, never>
|
||||
mediaStreamSource: MediaStreamAudioSourceNode | Record<string, never>
|
||||
audioContext?: AudioContext
|
||||
workletNode?: AudioWorkletNode
|
||||
mediaStreamSource?: MediaStreamAudioSourceNode
|
||||
audioStreamController?: ReadableStreamDefaultController<ArrayBuffer>
|
||||
abortController: AbortController
|
||||
result?: HearingTranscriptionResult & { recognition?: any }
|
||||
@@ -585,6 +595,19 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
onSpeechEnd?: (text: string) => void
|
||||
}
|
||||
}>()
|
||||
const streamingVadSession = shallowRef<{
|
||||
vad: Pick<ReturnType<typeof useVAD>, 'dispose'>
|
||||
lifecycle: ReturnType<typeof createVadStreamingSession>
|
||||
providerId: string
|
||||
callbacks: {
|
||||
onSentenceEnd?: (delta: string) => void
|
||||
onSpeechEnd?: (text: string) => void
|
||||
}
|
||||
activeSegment?: {
|
||||
audioChunks: ArrayBuffer[]
|
||||
audioStreamController?: ReadableStreamDefaultController<ArrayBuffer>
|
||||
}
|
||||
}>()
|
||||
|
||||
let asrSpan: Span | undefined
|
||||
|
||||
@@ -620,66 +643,9 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
return providersStore.getTranscriptionFeatures(providerId).supportsStreamInput
|
||||
})
|
||||
|
||||
const DEFAULT_SAMPLE_RATE = 16000
|
||||
const DEFAULT_STREAM_IDLE_TIMEOUT = 15000
|
||||
|
||||
function float32ToInt16(buffer: Float32Array) {
|
||||
const output = new Int16Array(buffer.length)
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const value = Math.max(-1, Math.min(1, buffer[i]))
|
||||
output[i] = value < 0 ? value * 0x8000 : value * 0x7FFF
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
async function createAudioStreamFromMediaStream(stream: MediaStream, sampleRate = DEFAULT_SAMPLE_RATE, onActivity?: () => void) {
|
||||
const audioContext = new AudioContext({ sampleRate, latencyHint: 'interactive' })
|
||||
await audioContext.audioWorklet.addModule(vadWorkletUrl)
|
||||
const workletNode = new AudioWorkletNode(audioContext, 'vad-audio-worklet-processor')
|
||||
|
||||
let audioStreamController: ReadableStreamDefaultController<ArrayBuffer> | undefined
|
||||
const audioStream = new ReadableStream<ArrayBuffer>({
|
||||
start(controller) {
|
||||
audioStreamController = controller
|
||||
},
|
||||
cancel: () => {
|
||||
audioStreamController = undefined
|
||||
},
|
||||
})
|
||||
|
||||
workletNode.port.onmessage = ({ data }: MessageEvent<{ buffer?: Float32Array }>) => {
|
||||
const buffer = data?.buffer
|
||||
if (!buffer || !audioStreamController)
|
||||
return
|
||||
|
||||
const pcm16 = float32ToInt16(buffer)
|
||||
// Clone buffer to avoid retaining underlying ArrayBuffer references
|
||||
audioStreamController.enqueue(pcm16.buffer.slice(0))
|
||||
onActivity?.()
|
||||
}
|
||||
|
||||
const mediaStreamSource = audioContext.createMediaStreamSource(stream)
|
||||
mediaStreamSource.connect(workletNode)
|
||||
|
||||
// Sink to avoid feedback/echo
|
||||
const silentGain = audioContext.createGain()
|
||||
silentGain.gain.value = 0
|
||||
workletNode.connect(silentGain)
|
||||
silentGain.connect(audioContext.destination)
|
||||
|
||||
return {
|
||||
audioContext,
|
||||
workletNode,
|
||||
mediaStreamSource,
|
||||
audioStream,
|
||||
get controller() {
|
||||
return audioStreamController
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function stopStreamingTranscription(abort?: boolean, disposeProviderId?: string) {
|
||||
async function stopRealtimeTranscription(abort?: boolean, disposeProviderId?: string) {
|
||||
const session = streamingSession.value
|
||||
if (!session)
|
||||
return
|
||||
@@ -749,12 +715,14 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
}
|
||||
catch {}
|
||||
|
||||
await tryCatch(() => {
|
||||
session.mediaStreamSource.disconnect()
|
||||
session.workletNode.port.onmessage = null
|
||||
session.workletNode.disconnect()
|
||||
})
|
||||
await tryCatch(() => session.audioContext.close())
|
||||
if (session.mediaStreamSource && session.workletNode && session.audioContext) {
|
||||
await tryCatch(() => {
|
||||
session.mediaStreamSource?.disconnect()
|
||||
session.workletNode!.port.onmessage = null
|
||||
session.workletNode?.disconnect()
|
||||
})
|
||||
await tryCatch(() => session.audioContext?.close())
|
||||
}
|
||||
|
||||
if (session.idleTimer)
|
||||
clearTimeout(session.idleTimer)
|
||||
@@ -787,13 +755,225 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
return text
|
||||
}
|
||||
|
||||
async function transcribeForMediaStream(stream: MediaStream, options?: {
|
||||
sampleRate?: number
|
||||
providerOptions?: Record<string, unknown>
|
||||
idleTimeoutMs?: number
|
||||
onSentenceEnd?: (delta: string) => void
|
||||
onSpeechEnd?: (text: string) => void
|
||||
}) {
|
||||
/** Finishes one VAD segment without aborting the Provider's final response. */
|
||||
async function finishRealtimeTranscription() {
|
||||
const session = streamingSession.value
|
||||
if (!session)
|
||||
return
|
||||
|
||||
try {
|
||||
session.audioStreamController?.close()
|
||||
}
|
||||
catch {}
|
||||
|
||||
if (session.result?.mode !== 'stream') {
|
||||
streamingSession.value = undefined
|
||||
return session.result?.text
|
||||
}
|
||||
|
||||
try {
|
||||
return await session.result.text
|
||||
}
|
||||
catch (err) {
|
||||
if (!isExpectedStreamStopError(err)) {
|
||||
error.value = errorMessage(err)
|
||||
console.error('Error finishing transcription:', error.value)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (streamingSession.value === session)
|
||||
streamingSession.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops the active VAD detector and any realtime transcription session. */
|
||||
async function stopStreamingTranscription(abort?: boolean, disposeProviderId?: string) {
|
||||
const vadSession = streamingVadSession.value
|
||||
if (vadSession) {
|
||||
streamingVadSession.value = undefined
|
||||
vadSession.vad.dispose()
|
||||
await vadSession.lifecycle.dispose()
|
||||
}
|
||||
|
||||
return await stopRealtimeTranscription(abort, disposeProviderId)
|
||||
}
|
||||
|
||||
function float32ToInt16(buffer: Float32Array) {
|
||||
const output = new Int16Array(buffer.length)
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const value = Math.max(-1, Math.min(1, buffer[i]))
|
||||
output[i] = value < 0 ? value * 0x8000 : value * 0x7FFF
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function enqueueVadAudio(segment: NonNullable<typeof streamingVadSession.value>['activeSegment'], buffer: Float32Array) {
|
||||
if (!segment)
|
||||
return
|
||||
|
||||
const pcm16 = float32ToInt16(buffer)
|
||||
const chunk = pcm16.buffer.slice(0)
|
||||
if (segment.audioStreamController) {
|
||||
segment.audioStreamController.enqueue(chunk)
|
||||
return
|
||||
}
|
||||
|
||||
segment.audioChunks.push(chunk)
|
||||
}
|
||||
|
||||
function createVadAudioStream(segment: NonNullable<typeof streamingVadSession.value>['activeSegment']) {
|
||||
if (!segment)
|
||||
throw new Error('VAD did not create an active speech segment.')
|
||||
|
||||
return new ReadableStream<ArrayBuffer>({
|
||||
start(controller) {
|
||||
segment.audioStreamController = controller
|
||||
for (const chunk of segment.audioChunks)
|
||||
controller.enqueue(chunk)
|
||||
segment.audioChunks.length = 0
|
||||
},
|
||||
cancel() {
|
||||
segment.audioStreamController = undefined
|
||||
segment.audioChunks.length = 0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function consumeRealtimeTranscriptionResult(
|
||||
session: NonNullable<typeof streamingSession.value>,
|
||||
result: HearingTranscriptionResult,
|
||||
) {
|
||||
if (result.mode !== 'stream' || !result.textStream)
|
||||
return
|
||||
|
||||
const sessionSpan = asrSpan
|
||||
const sessionCallbacks = session.callbacks
|
||||
void (async () => {
|
||||
let fullText = ''
|
||||
try {
|
||||
const reader = result.textStream.getReader()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
if (!value)
|
||||
continue
|
||||
|
||||
fullText += value
|
||||
sessionSpan?.addEvent(IOEvents.ASRSentenceEnd, { [IOAttributes.ASRText]: value })
|
||||
sessionCallbacks?.onSentenceEnd?.(value)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (!isExpectedStreamStopError(err))
|
||||
console.error('Error reading text stream:', err)
|
||||
}
|
||||
finally {
|
||||
sessionSpan?.setAttribute(IOAttributes.ASRText, fullText)
|
||||
sessionSpan?.end()
|
||||
if (asrSpan === sessionSpan)
|
||||
asrSpan = undefined
|
||||
sessionCallbacks?.onSpeechEnd?.(fullText)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
async function startVadRealtimeTranscription(
|
||||
providerId: string,
|
||||
options: MediaStreamTranscriptionOptions | undefined,
|
||||
vadSession: NonNullable<typeof streamingVadSession.value>,
|
||||
) {
|
||||
const segment = vadSession.activeSegment
|
||||
if (!segment)
|
||||
return
|
||||
|
||||
const provider = await providersStore.getProviderInstance<TranscriptionProviderWithExtraOptions<string, any>>(providerId)
|
||||
if (!provider)
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
|
||||
const abortController = new AbortController()
|
||||
const session: NonNullable<typeof streamingSession.value> = {
|
||||
audioStreamController: undefined as ReadableStreamDefaultController<ArrayBuffer> | undefined,
|
||||
abortController,
|
||||
providerId,
|
||||
callbacks: vadSession.callbacks,
|
||||
}
|
||||
const audioStream = createVadAudioStream(segment)
|
||||
session.audioStreamController = segment.audioStreamController
|
||||
streamingSession.value = session
|
||||
startStreamingAsrSpan(providerId)
|
||||
|
||||
const result = await hearingStore.transcription(
|
||||
providerId,
|
||||
provider,
|
||||
activeTranscriptionModel.value,
|
||||
{ inputAudioStream: audioStream },
|
||||
undefined,
|
||||
{
|
||||
providerOptions: {
|
||||
abortSignal: abortController.signal,
|
||||
...options?.providerOptions,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (streamingSession.value !== session)
|
||||
return
|
||||
|
||||
session.result = result
|
||||
consumeRealtimeTranscriptionResult(session, result)
|
||||
}
|
||||
|
||||
async function startVadStreamingTranscription(
|
||||
stream: MediaStream,
|
||||
providerId: string,
|
||||
options: MediaStreamTranscriptionOptions | undefined,
|
||||
) {
|
||||
let vadSession!: NonNullable<typeof streamingVadSession.value>
|
||||
const vad = useVAD(vadWorkletUrl, {
|
||||
onSpeechStart: () => {
|
||||
vadSession.activeSegment = { audioChunks: [] }
|
||||
vadSession.lifecycle.onSpeechStart()
|
||||
},
|
||||
onSpeechAudio: ({ buffer }) => {
|
||||
enqueueVadAudio(vadSession.activeSegment, buffer)
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
vadSession.lifecycle.onSpeechEnd()
|
||||
},
|
||||
})
|
||||
const lifecycle = createVadStreamingSession({
|
||||
start: async () => await startVadRealtimeTranscription(providerId, options, vadSession),
|
||||
stop: async () => {
|
||||
await finishRealtimeTranscription()
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = errorMessage(err)
|
||||
console.error('Error managing VAD streaming transcription:', error.value)
|
||||
},
|
||||
})
|
||||
vadSession = {
|
||||
vad,
|
||||
lifecycle,
|
||||
providerId,
|
||||
callbacks: {
|
||||
onSentenceEnd: options?.onSentenceEnd,
|
||||
onSpeechEnd: options?.onSpeechEnd,
|
||||
},
|
||||
}
|
||||
streamingVadSession.value = vadSession
|
||||
|
||||
await vad.init()
|
||||
if (!vad.loaded.value) {
|
||||
throw new Error(vad.inferenceError.value || 'Failed to initialize voice activity detection.')
|
||||
}
|
||||
|
||||
await vad.start(stream)
|
||||
}
|
||||
|
||||
async function transcribeForMediaStream(stream: MediaStream, options?: MediaStreamTranscriptionOptions) {
|
||||
console.info('[Hearing Pipeline] transcribeForMediaStream called', {
|
||||
supportsStreamInput: supportsStreamInput.value,
|
||||
hasStream: !!stream,
|
||||
@@ -974,136 +1154,24 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech
|
||||
return
|
||||
}
|
||||
|
||||
const provider = await providersStore.getProviderInstance<TranscriptionProviderWithExtraOptions<string, any>>(providerId)
|
||||
if (!provider) {
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
const idleTimeout = options?.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT
|
||||
|
||||
// If a session exists, reuse it unless new callbacks are provided.
|
||||
// The stream reader captures callbacks at creation time, so updated callbacks
|
||||
// require restarting the session to create a new reader.
|
||||
const existingSession = streamingSession.value
|
||||
if (existingSession) {
|
||||
const hasNewCallbacks = haveStreamingCallbacksChanged(existingSession.callbacks, {
|
||||
const existingVadSession = streamingVadSession.value
|
||||
if (existingVadSession) {
|
||||
const hasNewCallbacks = haveStreamingCallbacksChanged(existingVadSession.callbacks, {
|
||||
onSentenceEnd: options?.onSentenceEnd,
|
||||
onSpeechEnd: options?.onSpeechEnd,
|
||||
})
|
||||
|
||||
if (hasNewCallbacks) {
|
||||
console.info('[Hearing Pipeline] New callbacks provided, restarting session')
|
||||
await stopStreamingTranscription(false, existingSession.providerId)
|
||||
// Fall through to create a new session with updated callbacks
|
||||
console.info('[Hearing Pipeline] New callbacks provided, restarting VAD detection')
|
||||
await stopStreamingTranscription(false, existingVadSession.providerId)
|
||||
}
|
||||
else {
|
||||
// No callback changes: refresh idle timer and reuse session
|
||||
if (existingSession.idleTimer) {
|
||||
clearTimeout(existingSession.idleTimer)
|
||||
existingSession.idleTimer = setTimeout(async () => {
|
||||
await stopStreamingTranscription(false, existingSession.providerId)
|
||||
}, idleTimeout)
|
||||
}
|
||||
console.info('[Hearing Pipeline] VAD detection already active, reusing it')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
startStreamingAsrSpan(providerId)
|
||||
|
||||
const abortController = new AbortController()
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const bumpIdle = () => {
|
||||
if (idleTimer)
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(async () => {
|
||||
await stopStreamingTranscription(false, providerId)
|
||||
}, idleTimeout)
|
||||
}
|
||||
|
||||
const session = await createAudioStreamFromMediaStream(
|
||||
stream,
|
||||
options?.sampleRate ?? DEFAULT_SAMPLE_RATE,
|
||||
() => bumpIdle(),
|
||||
)
|
||||
|
||||
if (session.audioContext.state === 'suspended')
|
||||
await session.audioContext.resume()
|
||||
|
||||
bumpIdle()
|
||||
|
||||
const model = activeTranscriptionModel.value
|
||||
const result = await hearingStore.transcription(
|
||||
providerId,
|
||||
provider,
|
||||
model,
|
||||
{ inputAudioStream: session.audioStream },
|
||||
undefined,
|
||||
{
|
||||
providerOptions: {
|
||||
abortSignal: abortController.signal,
|
||||
...options?.providerOptions,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
streamingSession.value = {
|
||||
audioContext: session.audioContext,
|
||||
workletNode: session.workletNode,
|
||||
mediaStreamSource: session.mediaStreamSource,
|
||||
audioStreamController: session.controller,
|
||||
abortController,
|
||||
result,
|
||||
idleTimer,
|
||||
providerId,
|
||||
callbacks: {
|
||||
onSentenceEnd: options?.onSentenceEnd,
|
||||
onSpeechEnd: options?.onSpeechEnd,
|
||||
},
|
||||
}
|
||||
|
||||
// Stream out text deltas to caller without tearing down the session.
|
||||
if (result.mode === 'stream' && result.textStream) {
|
||||
void (async () => {
|
||||
// Capture callbacks from the session at the time the reader is created
|
||||
// This prevents cross-session leakage if the session is restarted before
|
||||
// this reader finishes (e.g., when navigating between pages or callbacks change)
|
||||
const sessionCallbacks = {
|
||||
onSentenceEnd: streamingSession.value?.callbacks?.onSentenceEnd,
|
||||
onSpeechEnd: streamingSession.value?.callbacks?.onSpeechEnd,
|
||||
}
|
||||
|
||||
let fullText = ''
|
||||
try {
|
||||
const reader = result.textStream.getReader()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
if (value) {
|
||||
fullText += value
|
||||
if (asrSpan)
|
||||
asrSpan.addEvent(IOEvents.ASRSentenceEnd, { [IOAttributes.ASRText]: value })
|
||||
// Use captured callbacks to avoid cross-session leakage
|
||||
sessionCallbacks.onSentenceEnd?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (!isExpectedStreamStopError(err))
|
||||
console.error('Error reading text stream:', err)
|
||||
}
|
||||
finally {
|
||||
if (asrSpan) {
|
||||
asrSpan.setAttribute(IOAttributes.ASRText, fullText)
|
||||
asrSpan.end()
|
||||
asrSpan = undefined
|
||||
}
|
||||
// Use captured callbacks to avoid cross-session leakage
|
||||
sessionCallbacks.onSpeechEnd?.(fullText)
|
||||
}
|
||||
})()
|
||||
}
|
||||
await startVadStreamingTranscription(stream, providerId, options)
|
||||
}
|
||||
catch (err) {
|
||||
endStreamingAsrSpan()
|
||||
|
||||
@@ -147,6 +147,10 @@ export class VAD implements BaseVAD {
|
||||
// Speech just started
|
||||
this.emit('speech-start', undefined)
|
||||
this.emit('status', { type: 'info', message: 'Speech detected' })
|
||||
this.emit('speech-audio', { buffer: this.createLeadingSpeechAudio(inputBuffer) })
|
||||
}
|
||||
else {
|
||||
this.emit('speech-audio', { buffer: inputBuffer.slice() })
|
||||
}
|
||||
|
||||
// Update state
|
||||
@@ -157,6 +161,7 @@ export class VAD implements BaseVAD {
|
||||
}
|
||||
|
||||
// At this point, we were recording but the current buffer is not speech
|
||||
this.emit('speech-audio', { buffer: inputBuffer.slice() })
|
||||
this.postSpeechSamples += inputBuffer.length
|
||||
|
||||
// Check if silence is long enough to consider speech ended
|
||||
@@ -164,6 +169,7 @@ export class VAD implements BaseVAD {
|
||||
// Check if the speech segment is long enough to process
|
||||
if (this.bufferPointer < minSpeechDurationSamples) {
|
||||
// Too short, reset without processing
|
||||
this.emit('speech-end', undefined)
|
||||
this.reset()
|
||||
|
||||
return
|
||||
@@ -174,6 +180,21 @@ export class VAD implements BaseVAD {
|
||||
}
|
||||
}
|
||||
|
||||
/** Combines VAD's retained pre-speech padding with the first detected speech chunk. */
|
||||
private createLeadingSpeechAudio(inputBuffer: Float32Array): Float32Array {
|
||||
const leadingLength = this.prevBuffers.reduce((total, buffer) => total + buffer.length, 0)
|
||||
const output = new Float32Array(leadingLength + inputBuffer.length)
|
||||
let offset = 0
|
||||
|
||||
for (const buffer of this.prevBuffers) {
|
||||
output.set(buffer, offset)
|
||||
offset += buffer.length
|
||||
}
|
||||
|
||||
output.set(inputBuffer, offset)
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect speech in an audio buffer
|
||||
*/
|
||||
|
||||
@@ -169,7 +169,7 @@ async function writeAudioToUpstream(audioStream: ReadableStream<Uint8Array>, ws:
|
||||
* - `audioStream` contains 16 kHz PCM chunks by default, matching the Hearing worklet output.
|
||||
*
|
||||
* Returns:
|
||||
* - A `text/event-stream` response consumable by the existing `streamAliyunTranscription` executor.
|
||||
* - A `text/event-stream` response consumable by the shared `streamTranscription` adapter.
|
||||
*/
|
||||
export function createAliyunNlsStreamResponse(options: CreateAliyunNlsStreamResponseOptions): Response {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
|
||||
Reference in New Issue
Block a user