refactor(stage-web,stage-pages,stage-ui): better structure for asr & vad

This commit is contained in:
Neko Ayaka
2026-08-09 18:22:29 +08:00
parent 98fa1f0855
commit 66d7ef207e
28 changed files with 913 additions and 428 deletions
+26 -14
View File
@@ -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 {}
@@ -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,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'
@@ -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`,
+3 -1
View File
@@ -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,
}
}
+2
View File
@@ -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'
@@ -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 })
})
})
+26 -1
View File
@@ -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
+266 -198
View File
@@ -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()
+21
View File
@@ -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>({