refactor(stage-*): moved vad state manager

This commit is contained in:
Neko Ayaka
2025-08-16 21:59:19 +08:00
parent 96ea63ba18
commit 33501a0857
14 changed files with 312 additions and 719 deletions
+1
View File
@@ -49,6 +49,7 @@
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/model": "catalog:",
"@xsai/shared": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/utils-chat": "catalog:",
@@ -1,112 +0,0 @@
// Language constants
const LANGUAGES: Record<string, string>[] = [
{ en: 'english' },
{ zh: 'chinese' },
{ 'zh-Hans': 'chinese' },
{ 'zh-Hant': 'chinese' },
{ 'zh-CN': 'chinese' },
{ 'zh-TW': 'chinese' },
{ 'zh-HK': 'chinese' },
{ de: 'german' },
{ es: 'spanish' },
{ ru: 'russian' },
{ ko: 'korean' },
{ fr: 'french' },
{ ja: 'japanese' },
{ pt: 'portuguese' },
{ tr: 'turkish' },
{ pl: 'polish' },
{ ca: 'catalan' },
{ nl: 'dutch' },
{ ar: 'arabic' },
{ sv: 'swedish' },
{ it: 'italian' },
{ id: 'indonesian' },
{ hi: 'hindi' },
{ fi: 'finnish' },
{ vi: 'vietnamese' },
{ he: 'hebrew' },
{ uk: 'ukrainian' },
{ el: 'greek' },
{ ms: 'malay' },
{ cs: 'czech' },
{ ro: 'romanian' },
{ da: 'danish' },
{ hu: 'hungarian' },
{ ta: 'tamil' },
{ no: 'norwegian' },
{ th: 'thai' },
{ ur: 'urdu' },
{ hr: 'croatian' },
{ bg: 'bulgarian' },
{ lt: 'lithuanian' },
{ la: 'latin' },
{ mi: 'maori' },
{ ml: 'malayalam' },
{ cy: 'welsh' },
{ sk: 'slovak' },
{ te: 'telugu' },
{ fa: 'persian' },
{ lv: 'latvian' },
{ bn: 'bengali' },
{ sr: 'serbian' },
{ az: 'azerbaijani' },
{ sl: 'slovenian' },
{ kn: 'kannada' },
{ et: 'estonian' },
{ mk: 'macedonian' },
{ br: 'breton' },
{ eu: 'basque' },
{ is: 'icelandic' },
{ hy: 'armenian' },
{ ne: 'nepali' },
{ mn: 'mongolian' },
{ bs: 'bosnian' },
{ kk: 'kazakh' },
{ sq: 'albanian' },
{ sw: 'swahili' },
{ gl: 'galician' },
{ mr: 'marathi' },
{ pa: 'punjabi' },
{ si: 'sinhala' },
{ km: 'khmer' },
{ sn: 'shona' },
{ yo: 'yoruba' },
{ so: 'somali' },
{ af: 'afrikaans' },
{ oc: 'occitan' },
{ ka: 'georgian' },
{ be: 'belarusian' },
{ tg: 'tajik' },
{ sd: 'sindhi' },
{ gu: 'gujarati' },
{ am: 'amharic' },
{ yi: 'yiddish' },
{ lo: 'lao' },
{ uz: 'uzbek' },
{ fo: 'faroese' },
{ ht: 'haitian creole' },
{ ps: 'pashto' },
{ tk: 'turkmen' },
{ nn: 'nynorsk' },
{ mt: 'maltese' },
{ sa: 'sanskrit' },
{ lb: 'luxembourgish' },
{ my: 'myanmar' },
{ bo: 'tibetan' },
{ tl: 'tagalog' },
{ mg: 'malagasy' },
{ as: 'assamese' },
{ tt: 'tatar' },
{ haw: 'hawaiian' },
{ ln: 'lingala' },
{ ha: 'hausa' },
{ ba: 'bashkir' },
{ jw: 'javanese' },
{ su: 'sundanese' },
]
export function mapLanguageCodeToName(code: string): string {
const lang = LANGUAGES.find(lang => lang[code])
return lang ? lang[code] : 'Unknown Language'
}
@@ -1,212 +0,0 @@
import { computed, readonly, ref } from 'vue'
import { useTauriCore } from '../tauri'
import { mapLanguageCodeToName } from './extract-whisper-languages'
export interface WhisperConfig {
modelSize: 'tiny' | 'base' | 'small' | 'medium' | 'large'
language?: string
temperature: number
beamSize: number
bestOf: number
}
export interface VADSegment {
id: string
audioData: Float32Array
startTime: number
endTime: number
probability: number
isComplete: boolean
}
export interface TranscriptionResult {
id: string
segmentId: string
text: string
language?: string
processingTimeMs: number
timestamp: number
}
export function useWhisperTranscription(config: Partial<WhisperConfig> = {}) {
const { invoke } = useTauriCore()
const isModelLoaded = ref(false)
const isLoading = ref(false)
const error = ref('')
const isProcessing = ref(false)
const whisperConfig = ref<WhisperConfig>({
modelSize: 'base',
language: undefined, // Auto-detect
temperature: 0.0,
beamSize: 5,
bestOf: 5,
...config,
})
const transcriptionQueue = ref<VADSegment[]>([])
const transcriptionResults = ref<TranscriptionResult[]>([])
const maxResults = 50
const onTranscriptionResultHooks = ref<((result: TranscriptionResult) => void)[]>([])
const currentTranscription = computed(() =>
transcriptionResults.value[transcriptionResults.value.length - 1],
)
async function loadModel(modelType: 'base' | 'largev3' | 'tiny' | 'medium' = 'base') {
if (isModelLoaded.value || isLoading.value)
return
isLoading.value = true
error.value = ''
try {
await invoke('plugin:ipc-audio-transcription-ort|load_ort_model_whisper', { modelType })
isModelLoaded.value = true
}
catch (err) {
error.value = err instanceof Error ? err.message : String(err)
console.error('Failed to load Whisper model:', err)
}
finally {
isLoading.value = false
}
}
async function transcribeSegment(segment: VADSegment, locale: string): Promise<TranscriptionResult | null> {
if (!isModelLoaded.value || !segment.isComplete)
return null
const startTime = performance.now()
try {
isProcessing.value = true
const audioArray = Array.from(segment.audioData)
const result = await invoke('plugin:ipc-audio-transcription-ort|ipc_audio_transcription', {
chunk: audioArray,
language: mapLanguageCodeToName(locale),
}) || ''
const transcription: TranscriptionResult = {
id: `transcription_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
segmentId: segment.id,
text: result?.trim() || '',
processingTimeMs: performance.now() - startTime,
timestamp: Date.now(),
}
// Add to results
transcriptionResults.value.push(transcription)
if (transcriptionResults.value.length > maxResults) {
transcriptionResults.value.shift()
}
// Notify hooks
onTranscriptionResultHooks.value.forEach(cb => cb(transcription))
return transcription
}
catch (err) {
error.value = err instanceof Error ? err.message : String(err)
console.error('Transcription error:', err)
return null
}
finally {
isProcessing.value = false
}
}
async function transcribeAudioData(audioData: Float32Array, locale: string): Promise<TranscriptionResult | null> {
if (!isModelLoaded.value)
return null
const fakeSegment: VADSegment = {
id: `manual_${Date.now()}`,
audioData,
startTime: 0,
endTime: (audioData.length / 16000) * 1000,
probability: 1.0,
isComplete: true,
}
return await transcribeSegment(fakeSegment, locale)
}
function queueSegment(segment: VADSegment, locale: string) {
if (segment.isComplete) {
transcriptionQueue.value.push(segment)
processQueue(locale)
}
}
async function processQueue(locale: string) {
if (isProcessing.value || transcriptionQueue.value.length === 0)
return
const segment = transcriptionQueue.value.shift()
if (segment) {
await transcribeSegment(segment, locale)
// Process next item in queue
if (transcriptionQueue.value.length > 0) {
setTimeout(() => processQueue(locale), 100)
}
}
}
async function onTranscriptionResult(cb: (result: TranscriptionResult) => void) {
onTranscriptionResultHooks.value.push(cb)
// Immediately call with current results
transcriptionResults.value.forEach(result => cb(result))
// Return a cleanup function
return () => {
const index = onTranscriptionResultHooks.value.indexOf(cb)
if (index !== -1) {
onTranscriptionResultHooks.value.splice(index, 1)
}
}
}
function clearResults() {
transcriptionResults.value = []
}
function clearQueue() {
transcriptionQueue.value = []
}
function getResultBySegmentId(segmentId: string): TranscriptionResult | undefined {
return transcriptionResults.value.find(r => r.segmentId === segmentId)
}
return {
// State
isModelLoaded: readonly(isModelLoaded),
isLoading: readonly(isLoading),
isProcessing: readonly(isProcessing),
error: readonly(error),
// Configuration
config: whisperConfig,
// Results
transcriptionResults: readonly(transcriptionResults),
currentTranscription,
queueLength: computed(() => transcriptionQueue.value.length),
// Controls
loadModel,
transcribeSegment,
transcribeAudioData,
queueSegment,
clearResults,
clearQueue,
getResultBySegmentId,
onTranscriptionResult,
}
}
@@ -11,7 +11,7 @@ import { useI18n } from 'vue-i18n'
import workletUrl from '../../../tauri/vad/process.worklet?worker&url'
import { createVAD, VADAudioManager } from '../../../tauri/vad'
import { createVAD, createVADStates } from '../../../tauri/vad'
const { t } = useI18n()
@@ -50,7 +50,7 @@ const speakingThreshold = ref(25) // 0-100 (for volume-based fallback)
const monitorVolume = ref(50) // 0-100
// VAD integration
const vadManager = ref<VADAudioManager>()
const vadManager = ref<ReturnType<typeof createVADStates>>()
const isVADModelLoaded = ref(false)
const isLoadingVADModel = ref(false)
const useVADModel = ref(true) // Toggle between VAD and volume-based detection
@@ -119,7 +119,7 @@ async function loadVADModel() {
})
// Create and initialize audio manager
const manager = new VADAudioManager(vad, {
const manager = createVADStates(vad, workletUrl, {
minChunkSize: 512,
// NOTICE: VAD will have it's own audio context since
// it needs special sample rate and latency settings
@@ -129,7 +129,7 @@ async function loadVADModel() {
},
})
await manager.initialize(workletUrl)
await manager.initialize()
vadManager.value = manager
isVADModelLoaded.value = true
}
+1 -2
View File
@@ -1,4 +1,3 @@
export { VADAudioManager } from './manager'
export { createVADStates } from './manager'
export type { VADAudioOptions } from './manager'
export { createVAD, VAD } from './vad'
export type { VADConfig, VADEventCallback, VADEvents } from './vad'
+2 -150
View File
@@ -1,150 +1,2 @@
import type { VAD, VADConfig } from './vad'
export interface VADAudioOptions {
/**
* Audio context options
*/
audioContextOptions?: AudioContextOptions
/**
* The minimum size of audio chunks to process
*/
minChunkSize?: number
/**
* VAD configuration options
*/
vadConfig?: Partial<VADConfig>
}
/**
* Manages audio input and worklet processing for the VAD module
*/
export class VADAudioManager {
private audioContext: AudioContext | null = null
private audioWorkletNode: AudioWorkletNode | null = null
private mediaStream: MediaStream | null = null
private sourceNode: MediaStreamAudioSourceNode | null = null
private vad: VAD // Updated type
private workletInitialized: boolean = false
/**
* Create a new VAD audio manager
*/
constructor(vad: VAD, options: VADAudioOptions = {}) { // Updated parameter type
this.vad = vad
// Create audio context with user options or defaults
this.audioContext = new AudioContext(options.audioContextOptions || {
sampleRate: 16000, // Match the VAD sample rate
latencyHint: 'interactive',
})
}
/**
* Initialize the audio worklet and connect to microphone
*/
public async initialize(workletUrl: string): Promise<void> {
if (!this.audioContext) {
throw new Error('Audio context not created')
}
try {
if (!this.workletInitialized) {
await this.audioContext.audioWorklet.addModule(workletUrl)
URL.revokeObjectURL(workletUrl)
this.workletInitialized = true
}
// Create the worklet node
this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'vad-processor')
// Set up message handling from the worklet
this.audioWorkletNode.port.onmessage = async (event) => {
const { buffer } = event.data
if (buffer && buffer.length > 0) {
await this.vad.processAudio(new Float32Array(buffer))
}
}
}
catch (error) {
console.error('Failed to initialize audio worklet:', error)
throw error
}
}
/**
* Start capturing audio from the microphone
*/
public async start(mediaStream: MediaStream): Promise<void> {
if (!this.audioContext || !this.audioWorkletNode) {
throw new Error('Audio system not initialized. Call initialize() first.')
}
try {
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
// Request microphone access
this.mediaStream = mediaStream
// Create source node and connect to worklet
this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream)
this.sourceNode.connect(this.audioWorkletNode)
// Connect worklet to a silent destination (to keep the audio graph active)
// Using a GainNode with gain=0 to ensure no sound is output
const silentGain = this.audioContext.createGain()
silentGain.gain.value = 0
this.audioWorkletNode.connect(silentGain)
silentGain.connect(this.audioContext.destination)
}
catch (error) {
console.error('Failed to start microphone:', error)
throw error
}
}
/**
* Stop capturing audio
*/
public stop(): void {
// Disconnect nodes
if (this.sourceNode && this.audioWorkletNode) {
this.sourceNode.disconnect()
this.audioWorkletNode.disconnect()
}
// Stop all tracks in the media stream
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
// Suspend the audio context rather than closing it
// This allows us to reuse it later
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.suspend()
}
this.sourceNode = null
this.audioWorkletNode?.disconnect()
this.audioWorkletNode = null
}
/**
* Clean up all resources
*/
public dispose(): void {
this.stop()
// Now fully close the audio context
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close()
this.audioContext = null
}
this.workletInitialized = false
}
}
export type { VADAudioOptions } from '@proj-airi/stage-ui/libs/audio/vad'
export { createVADStates } from '@proj-airi/stage-ui/libs/audio/vad'
+8 -27
View File
@@ -1,28 +1,9 @@
import type { BaseVAD, BaseVADConfig, VADEventCallback, VADEvents } from '@proj-airi/stage-ui/libs/audio/vad'
import { invoke } from '../invoke'
export interface VADConfig {
sampleRate: number
speechThreshold: number
exitThreshold: number
minSilenceDurationMs: number
speechPadMs: number
minSpeechDurationMs: number
maxBufferDuration: number
newBufferSize: number
}
export interface VADEvents {
'speech-start': void
'speech-end': void
'speech-ready': { buffer: Float32Array, duration: number }
'status': { type: string, message: string }
'debug': { message: string, data?: any }
}
export type VADEventCallback<K extends keyof VADEvents> = (event: VADEvents[K]) => void
export class VAD {
private config: VADConfig
export class VAD implements BaseVAD {
private config: BaseVADConfig
private state: Float32Array = new Float32Array(2 * 1 * 128) // 2, 1, 128
private buffer: Float32Array
private bufferPointer: number = 0
@@ -33,8 +14,8 @@ export class VAD {
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
private isReady: boolean = false
constructor(userConfig: Partial<VADConfig> = {}) {
const defaultConfig: VADConfig = {
constructor(userConfig: Partial<BaseVADConfig> = {}) {
const defaultConfig: BaseVADConfig = {
sampleRate: 16000,
speechThreshold: 0.3,
exitThreshold: 0.1,
@@ -215,7 +196,7 @@ export class VAD {
this.prevBuffers = []
}
public updateConfig(newConfig: Partial<VADConfig>): void {
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
this.config = { ...this.config, ...newConfig }
if (newConfig.maxBufferDuration || newConfig.sampleRate) {
@@ -229,7 +210,7 @@ export class VAD {
}
}
export async function createVAD(config?: Partial<VADConfig>): Promise<VAD> {
export async function createVAD(config?: Partial<BaseVADConfig>): Promise<VAD> {
const vad = new VAD(config)
await vad.initialize()
return vad
@@ -11,7 +11,7 @@ import { useI18n } from 'vue-i18n'
import workletUrl from '../../../workers/vad/process.worklet?worker&url'
import { createVAD, VADAudioManager } from '../../../workers/vad'
import { createVAD, createVADStates } from '../../../workers/vad'
const { t } = useI18n()
@@ -51,7 +51,7 @@ const speakingThreshold = ref(25) // 0-100 (for volume-based fallback)
const monitorVolume = ref(50) // 0-100
// VAD integration
const vadManager = ref<VADAudioManager>()
const vadManager = ref<ReturnType<typeof createVADStates>>()
const isVADModelLoaded = ref(false)
const isLoadingVADModel = ref(false)
const useVADModel = ref(true) // Toggle between VAD and volume-based detection
@@ -120,7 +120,7 @@ async function loadVADModel() {
})
// Create and initialize audio manager
const manager = new VADAudioManager(vad, {
const manager = createVADStates(vad, workletUrl, {
minChunkSize: 512,
// NOTICE: VAD will have it's own audio context since
// it needs special sample rate and latency settings
@@ -130,7 +130,7 @@ async function loadVADModel() {
},
})
await manager.initialize(workletUrl)
await manager.initialize()
vadManager.value = manager
isVADModelLoaded.value = true
}
+1 -2
View File
@@ -1,4 +1,3 @@
export { VADAudioManager } from './manager'
export { createVADStates } from './manager'
export type { VADAudioOptions } from './manager'
export { createVAD, VAD } from './vad'
export type { VADConfig, VADEventCallback, VADEvents } from './vad'
+2 -154
View File
@@ -1,154 +1,2 @@
// vad-audio-manager.ts
import type { VAD, VADConfig } from './vad'
export interface VADAudioOptions {
/**
* Audio context options
*/
audioContextOptions?: AudioContextOptions
/**
* The minimum size of audio chunks to process
*/
minChunkSize?: number
/**
* VAD configuration options
*/
vadConfig?: Partial<VADConfig>
}
/**
* Manages audio input and worklet processing for the VAD module
*/
export class VADAudioManager {
private audioContext: AudioContext | null = null
private audioWorkletNode: AudioWorkletNode | null = null
private mediaStream: MediaStream | null = null
private sourceNode: MediaStreamAudioSourceNode | null = null
private vad: VAD
// private minChunkSize: number;
private workletInitialized: boolean = false
/**
* Create a new VAD audio manager
*/
constructor(vad: VAD, options: VADAudioOptions = {}) {
this.vad = vad
// this.minChunkSize = options.minChunkSize || 512;
// Create audio context with user options or defaults
this.audioContext = new AudioContext(options.audioContextOptions || {
sampleRate: 16000, // Match the VAD sample rate
latencyHint: 'interactive',
})
}
/**
* Initialize the audio worklet and connect to microphone
*/
public async initialize(workletUrl: string): Promise<void> {
if (!this.audioContext) {
throw new Error('Audio context not created')
}
try {
if (!this.workletInitialized) {
await this.audioContext.audioWorklet.addModule(workletUrl)
URL.revokeObjectURL(workletUrl)
this.workletInitialized = true
}
// Create the worklet node
this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'vad-processor')
// Set up message handling from the worklet
this.audioWorkletNode.port.onmessage = async (event) => {
const { buffer } = event.data
if (buffer && buffer.length > 0) {
await this.vad.processAudio(new Float32Array(buffer))
}
}
}
catch (error) {
console.error('Failed to initialize audio worklet:', error)
throw error
}
}
/**
* Start capturing audio from the microphone
*/
public async start(mediaStream: MediaStream): Promise<void> {
if (!this.audioContext || !this.audioWorkletNode) {
throw new Error('Audio system not initialized. Call initialize() first.')
}
try {
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
// Request microphone access
this.mediaStream = mediaStream
// Create source node and connect to worklet
this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream)
this.sourceNode.connect(this.audioWorkletNode)
// Connect worklet to a silent destination (to keep the audio graph active)
// Using a GainNode with gain=0 to ensure no sound is output
const silentGain = this.audioContext.createGain()
silentGain.gain.value = 0
this.audioWorkletNode.connect(silentGain)
silentGain.connect(this.audioContext.destination)
}
catch (error) {
console.error('Failed to start microphone:', error)
throw error
}
}
/**
* Stop capturing audio
*/
public stop(): void {
// Disconnect nodes
if (this.sourceNode && this.audioWorkletNode) {
this.sourceNode.disconnect()
this.audioWorkletNode.disconnect()
}
// Stop all tracks in the media stream
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
// Suspend the audio context rather than closing it
// This allows us to reuse it later
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.suspend()
}
this.sourceNode = null
this.audioWorkletNode?.disconnect()
this.audioWorkletNode = null
}
/**
* Clean up all resources
*/
public dispose(): void {
this.stop()
// Now fully close the audio context
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close()
this.audioContext = null
}
this.workletInitialized = false
}
}
export type { VADAudioOptions } from '@proj-airi/stage-ui/libs/audio/vad'
export { createVADStates } from '@proj-airi/stage-ui/libs/audio/vad'
+7 -42
View File
@@ -1,48 +1,13 @@
import type { PreTrainedModel } from '@huggingface/transformers'
import type { BaseVAD, BaseVADConfig, VADEventCallback, VADEvents } from '@proj-airi/stage-ui/libs/audio/vad'
import { AutoModel, Tensor } from '@huggingface/transformers'
// Default configuration parameters
export interface VADConfig {
// Sample rate of the audio
sampleRate: number
// Probabilities above this value are considered speech
speechThreshold: number
// Threshold to exit speech state
exitThreshold: number
// Minimum silence duration to consider speech ended (ms)
minSilenceDurationMs: number
// Padding to add before and after speech (ms)
speechPadMs: number
// Minimum duration of speech to consider valid (ms)
minSpeechDurationMs: number
// Maximum buffer duration in seconds
maxBufferDuration: number
// Size of input buffers from audio source
newBufferSize: number
}
export interface VADEvents {
// Emitted when speech is detected
'speech-start': void
// Emitted when speech has ended
'speech-end': void
// Emitted when a complete speech segment is ready for transcription
'speech-ready': { buffer: Float32Array, duration: number }
// Emitted for status updates and errors
'status': { type: string, message: string }
// Debug info
'debug': { message: string, data?: any }
}
export type VADEventCallback<K extends keyof VADEvents>
= (event: VADEvents[K]) => void
/**
* Voice Activity Detection processor
*/
export class VAD {
private config: VADConfig
export class VAD implements BaseVAD {
private config: BaseVADConfig
private model: PreTrainedModel | undefined
private state: Tensor
private sampleRateTensor: Tensor
@@ -55,9 +20,9 @@ export class VAD {
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
private isReady: boolean = false
constructor(userConfig: Partial<VADConfig> = {}) {
constructor(userConfig: Partial<BaseVADConfig> = {}) {
// Default configuration
const defaultConfig: VADConfig = {
const defaultConfig: BaseVADConfig = {
sampleRate: 16000,
speechThreshold: 0.3,
exitThreshold: 0.1,
@@ -290,7 +255,7 @@ export class VAD {
/**
* Update configuration
*/
public updateConfig(newConfig: Partial<VADConfig>): void {
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
this.config = { ...this.config, ...newConfig }
// If buffer size changed, create a new buffer
@@ -325,7 +290,7 @@ export class VAD {
/**
* Create a VAD processor with the given configuration
*/
export async function createVAD(config?: Partial<VADConfig>): Promise<VAD> {
export async function createVAD(config?: Partial<BaseVADConfig>): Promise<VAD> {
const vad = new VAD(config)
await vad.initialize()
return vad
+163
View File
@@ -0,0 +1,163 @@
export interface BaseVADConfig {
// Sample rate of the audio
sampleRate: number
// Probabilities above this value are considered speech
speechThreshold: number
// Threshold to exit speech state
exitThreshold: number
// Minimum silence duration to consider speech ended (ms)
minSilenceDurationMs: number
// Padding to add before and after speech (ms)
speechPadMs: number
// Minimum duration of speech to consider valid (ms)
minSpeechDurationMs: number
// Maximum buffer duration in seconds
maxBufferDuration: number
// Size of input buffers from audio source
newBufferSize: number
}
export interface VADEvents {
// Emitted when speech is detected
'speech-start': void
// Emitted when speech has ended
'speech-end': void
// Emitted when a complete speech segment is ready for transcription
'speech-ready': { buffer: Float32Array, duration: number }
// Emitted for status updates and errors
'status': { type: string, message: string }
// Debug info
'debug': { message: string, data?: any }
}
export type VADEventCallback<K extends keyof VADEvents> = (event: VADEvents[K]) => void
export interface BaseVAD {
initialize: () => Promise<void>
processAudio: (inputBuffer: Float32Array) => Promise<void>
on: <K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>) => void
off: <K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>) => void
}
export interface VADAudioOptions {
/**
* Audio context options
*/
audioContextOptions?: AudioContextOptions
/**
* The minimum size of audio chunks to process
*/
minChunkSize?: number
/**
* VAD configuration options
*/
vadConfig?: Partial<BaseVADConfig>
}
export function createVADStates(vad: BaseVAD, vadAudioWorkletUrl: string, options?: VADAudioOptions) {
let audioWorkletNode: AudioWorkletNode | null
let mediaStream: MediaStream | null
let sourceNode: MediaStreamAudioSourceNode | null
let workletInitialized: boolean
const {
audioContextOptions = {
sampleRate: 16000,
latencyHint: 'interactive',
},
} = options || {}
let audioContext = new AudioContext(audioContextOptions)
async function initialize() {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new AudioContext(audioContextOptions)
}
try {
if (!workletInitialized) {
await audioContext.audioWorklet.addModule(vadAudioWorkletUrl)
workletInitialized = true
}
audioWorkletNode = new AudioWorkletNode(audioContext, 'vad-audio-worklet-processor')
audioWorkletNode.port.onmessage = async (event) => {
const { buffer } = event.data
if (buffer && buffer.length > 0) {
await vad.processAudio(new Float32Array(buffer))
}
}
}
catch (error) {
console.error('Failed to initialize audio worklet:', error)
throw error
}
}
async function start(stream: MediaStream) {
if (!audioContext || !audioWorkletNode) {
throw new Error('Audio system not initialized. Call initialize() first.')
}
try {
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
// Request microphone access
mediaStream = stream
// Create source node and connect to worklet
sourceNode = audioContext.createMediaStreamSource(mediaStream)
sourceNode.connect(audioWorkletNode)
// Connect worklet to a silent destination (to keep the audio graph active)
// Using a GainNode with gain=0 to ensure no sound is output
const silentGain = audioContext.createGain()
silentGain.gain.value = 0
audioWorkletNode.connect(silentGain)
silentGain.connect(audioContext.destination)
}
catch (error) {
console.error('Failed to start microphone:', error)
throw error
}
}
function stop() {
if (sourceNode) {
sourceNode.disconnect()
sourceNode = null
}
if (audioWorkletNode) {
audioWorkletNode.disconnect()
audioWorkletNode = null
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
mediaStream = null
}
if (audioContext) {
audioContext.suspend()
}
}
function dispose() {
stop()
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
workletInitialized = false
}
return {
initialize,
start,
stop,
dispose,
}
}
@@ -7,7 +7,7 @@ import { computed, ref } from 'vue'
import { useProvidersStore } from '../providers'
export const useHearingStore = defineStore('hearing', () => {
export const useHearingStore = defineStore('hearing-store', () => {
const providersStore = useProvidersStore()
const { allAudioTranscriptionProvidersMetadata } = storeToRefs(providersStore)
+118 -9
View File
@@ -37,7 +37,7 @@ catalogs:
specifier: ^0.3.4
version: 0.3.4
'@xsai/shared':
specifier: ^0.3.4
specifier: 0.3.4
version: 0.3.4
'@xsai/shared-chat':
specifier: ^0.3.4
@@ -427,6 +427,9 @@ importers:
'@xsai/model':
specifier: 'catalog:'
version: 0.3.4
'@xsai/shared':
specifier: 'catalog:'
version: 0.3.4
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.3.4
@@ -1128,7 +1131,7 @@ importers:
version: 0.1.3
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
vitepress:
specifier: ^2.0.0-alpha.9
version: 2.0.0-alpha.9(@types/node@24.1.0)(change-case@5.4.4)(fuse.js@7.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(nprogress@0.2.0)(postcss@8.5.6)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0)
@@ -1171,7 +1174,7 @@ importers:
devDependencies:
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
packages/memory-pgvector:
dependencies:
@@ -1554,7 +1557,7 @@ importers:
version: 1.2.4(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(esbuild@0.25.8)(rollup@4.46.2)(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
vite:
specifier: ^6.3.5
version: 6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
@@ -6307,6 +6310,9 @@ packages:
'@xsai/shared@0.3.4':
resolution: {integrity: sha512-Age17VqIqiu3X/yTlEP+tQRqY9Xb/0yTlxkvPIN9HAeky0WGWkXAt5RIgR0GMgwOVjBRYXsyZA5jYvCbmvF5hQ==}
'@xsai/shared@0.3.5':
resolution: {integrity: sha512-gxWH+9UjhXgqqKeU/o9UteC/ih1FzxEEsmPojImd/jvq4j/wtIg4aP3dCFnu+EhCT/MRQ38dkBSra99iY9cC3w==}
'@xsai/stream-text@0.3.4':
resolution: {integrity: sha512-5hI1zvBGwC8J9BiuChHwIxIJOetIwl1kmt4OR9b/CGLuhK1OR5JnxhbMcwXjJKqfOMKMkqM74KHTQG+QSVfsGg==}
@@ -17879,7 +17885,7 @@ snapshots:
'@xsai-ext/shared-providers@0.2.2':
dependencies:
'@xsai/shared': 0.3.4
'@xsai/shared': 0.3.5
'@xsai-ext/shared-providers@0.3.4':
dependencies:
@@ -17933,6 +17939,8 @@ snapshots:
'@xsai/shared@0.3.4': {}
'@xsai/shared@0.3.5': {}
'@xsai/stream-text@0.3.4':
dependencies:
'@xsai/shared-chat': 0.3.4
@@ -18369,6 +18377,107 @@ snapshots:
- yaml
optional: true
astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0):
dependencies:
'@astrojs/compiler': 2.12.2
'@astrojs/internal-helpers': 0.6.1
'@astrojs/markdown-remark': 6.3.2
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 2.4.0(encoding@0.1.13)
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
acorn: 8.15.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.3.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.0.2
cssesc: 3.0.0
debug: 4.4.1
deterministic-object-hash: 2.0.2
devalue: 5.1.1
diff: 5.2.0
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.25.8
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.3.0
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.1.0
js-yaml: 4.1.0
kleur: 4.1.5
magic-string: 0.30.17
magicast: 0.3.5
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.0
package-manager-detector: 1.3.0
picomatch: 4.0.3
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.2
shiki: 3.9.1
tinyexec: 0.3.2
tinyglobby: 0.2.14
tsconfck: 3.1.6(typescript@5.9.2)
ultrahtml: 1.6.0
unifont: 0.5.2
unist-util-visit: 5.0.0
unstorage: 1.16.0
vfile: 6.0.3
vite: 6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
vitefu: 1.0.7(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.2)(zod@3.25.76)
optionalDependencies:
sharp: 0.33.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/kv'
- aws4fetch
- db0
- encoding
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
optional: true
async-mutex@0.3.2:
dependencies:
tslib: 2.8.1
@@ -25217,7 +25326,7 @@ snapshots:
rollup: 4.46.2
vite: rolldown-vite@7.0.12(@types/node@24.1.0)(esbuild@0.25.8)(jiti@2.5.1)(less@4.4.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
unplugin: 2.3.5
@@ -25225,13 +25334,13 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.46.2)
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.46.2)
astro: 5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0)
astro: 5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0)
esbuild: 0.25.8
rolldown: 1.0.0-beta.30
rollup: 4.46.2
vite: 6.3.5(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.46.2))(astro@5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0))(esbuild@0.25.8)(rolldown@1.0.0-beta.30)(rollup@4.46.2)(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
unplugin: 2.3.5
@@ -25239,7 +25348,7 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.46.2)
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.46.2)
astro: 5.10.1(@types/node@24.1.0)(encoding@0.1.13)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0)
astro: 5.10.1(@types/node@24.1.0)(jiti@2.5.1)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.46.2)(terser@5.43.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.0)
esbuild: 0.25.8
rolldown: 1.0.0-beta.30
rollup: 4.46.2