From 33501a085707dbda3432dea48dba5bc6d9107211 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sat, 16 Aug 2025 21:58:59 +0800 Subject: [PATCH] refactor(stage-*): moved vad state manager --- apps/stage-tamagotchi/package.json | 1 + .../audio/extract-whisper-languages.ts | 112 --------- .../src/composables/audio/extract-whisper.ts | 212 ------------------ .../src/pages/settings/modules/hearing.vue | 8 +- apps/stage-tamagotchi/src/tauri/vad/index.ts | 3 +- .../stage-tamagotchi/src/tauri/vad/manager.ts | 152 +------------ apps/stage-tamagotchi/src/tauri/vad/vad.ts | 35 +-- .../src/pages/settings/modules/hearing.vue | 8 +- apps/stage-web/src/workers/vad/index.ts | 3 +- apps/stage-web/src/workers/vad/manager.ts | 156 +------------ apps/stage-web/src/workers/vad/vad.ts | 49 +--- packages/stage-ui/src/libs/audio/vad.ts | 163 ++++++++++++++ .../stage-ui/src/stores/modules/hearing.ts | 2 +- pnpm-lock.yaml | 127 ++++++++++- 14 files changed, 312 insertions(+), 719 deletions(-) delete mode 100644 apps/stage-tamagotchi/src/composables/audio/extract-whisper-languages.ts delete mode 100644 apps/stage-tamagotchi/src/composables/audio/extract-whisper.ts create mode 100644 packages/stage-ui/src/libs/audio/vad.ts diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 2f39bc074..4b6f15b61 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -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:", diff --git a/apps/stage-tamagotchi/src/composables/audio/extract-whisper-languages.ts b/apps/stage-tamagotchi/src/composables/audio/extract-whisper-languages.ts deleted file mode 100644 index b36c72730..000000000 --- a/apps/stage-tamagotchi/src/composables/audio/extract-whisper-languages.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Language constants -const LANGUAGES: Record[] = [ - { 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' -} diff --git a/apps/stage-tamagotchi/src/composables/audio/extract-whisper.ts b/apps/stage-tamagotchi/src/composables/audio/extract-whisper.ts deleted file mode 100644 index b4a89c629..000000000 --- a/apps/stage-tamagotchi/src/composables/audio/extract-whisper.ts +++ /dev/null @@ -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 = {}) { - const { invoke } = useTauriCore() - - const isModelLoaded = ref(false) - const isLoading = ref(false) - const error = ref('') - const isProcessing = ref(false) - - const whisperConfig = ref({ - modelSize: 'base', - language: undefined, // Auto-detect - temperature: 0.0, - beamSize: 5, - bestOf: 5, - ...config, - }) - - const transcriptionQueue = ref([]) - const transcriptionResults = ref([]) - 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 { - 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 { - 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, - } -} diff --git a/apps/stage-tamagotchi/src/pages/settings/modules/hearing.vue b/apps/stage-tamagotchi/src/pages/settings/modules/hearing.vue index da1232ba9..02afe6424 100644 --- a/apps/stage-tamagotchi/src/pages/settings/modules/hearing.vue +++ b/apps/stage-tamagotchi/src/pages/settings/modules/hearing.vue @@ -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() +const vadManager = ref>() 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 } diff --git a/apps/stage-tamagotchi/src/tauri/vad/index.ts b/apps/stage-tamagotchi/src/tauri/vad/index.ts index 2591dd3da..753421a38 100644 --- a/apps/stage-tamagotchi/src/tauri/vad/index.ts +++ b/apps/stage-tamagotchi/src/tauri/vad/index.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/tauri/vad/manager.ts b/apps/stage-tamagotchi/src/tauri/vad/manager.ts index f409b2820..8fc1108e2 100644 --- a/apps/stage-tamagotchi/src/tauri/vad/manager.ts +++ b/apps/stage-tamagotchi/src/tauri/vad/manager.ts @@ -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 -} - -/** - * 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 { - 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 { - 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' diff --git a/apps/stage-tamagotchi/src/tauri/vad/vad.ts b/apps/stage-tamagotchi/src/tauri/vad/vad.ts index e2d69ab06..44d96fe9c 100644 --- a/apps/stage-tamagotchi/src/tauri/vad/vad.ts +++ b/apps/stage-tamagotchi/src/tauri/vad/vad.ts @@ -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 = (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[]>> = {} private isReady: boolean = false - constructor(userConfig: Partial = {}) { - const defaultConfig: VADConfig = { + constructor(userConfig: Partial = {}) { + const defaultConfig: BaseVADConfig = { sampleRate: 16000, speechThreshold: 0.3, exitThreshold: 0.1, @@ -215,7 +196,7 @@ export class VAD { this.prevBuffers = [] } - public updateConfig(newConfig: Partial): void { + public updateConfig(newConfig: Partial): void { this.config = { ...this.config, ...newConfig } if (newConfig.maxBufferDuration || newConfig.sampleRate) { @@ -229,7 +210,7 @@ export class VAD { } } -export async function createVAD(config?: Partial): Promise { +export async function createVAD(config?: Partial): Promise { const vad = new VAD(config) await vad.initialize() return vad diff --git a/apps/stage-web/src/pages/settings/modules/hearing.vue b/apps/stage-web/src/pages/settings/modules/hearing.vue index 9ecb514e2..07961243a 100644 --- a/apps/stage-web/src/pages/settings/modules/hearing.vue +++ b/apps/stage-web/src/pages/settings/modules/hearing.vue @@ -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() +const vadManager = ref>() 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 } diff --git a/apps/stage-web/src/workers/vad/index.ts b/apps/stage-web/src/workers/vad/index.ts index 2591dd3da..753421a38 100644 --- a/apps/stage-web/src/workers/vad/index.ts +++ b/apps/stage-web/src/workers/vad/index.ts @@ -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' diff --git a/apps/stage-web/src/workers/vad/manager.ts b/apps/stage-web/src/workers/vad/manager.ts index 9ea0c27c2..8fc1108e2 100644 --- a/apps/stage-web/src/workers/vad/manager.ts +++ b/apps/stage-web/src/workers/vad/manager.ts @@ -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 -} - -/** - * 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 { - 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 { - 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' diff --git a/apps/stage-web/src/workers/vad/vad.ts b/apps/stage-web/src/workers/vad/vad.ts index 52c3059f3..17564402c 100644 --- a/apps/stage-web/src/workers/vad/vad.ts +++ b/apps/stage-web/src/workers/vad/vad.ts @@ -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 - = (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[]>> = {} private isReady: boolean = false - constructor(userConfig: Partial = {}) { + constructor(userConfig: Partial = {}) { // 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): void { + public updateConfig(newConfig: Partial): 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): Promise { +export async function createVAD(config?: Partial): Promise { const vad = new VAD(config) await vad.initialize() return vad diff --git a/packages/stage-ui/src/libs/audio/vad.ts b/packages/stage-ui/src/libs/audio/vad.ts new file mode 100644 index 000000000..aeae52e7f --- /dev/null +++ b/packages/stage-ui/src/libs/audio/vad.ts @@ -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 = (event: VADEvents[K]) => void + +export interface BaseVAD { + initialize: () => Promise + processAudio: (inputBuffer: Float32Array) => Promise + on: (event: K, callback: VADEventCallback) => void + off: (event: K, callback: VADEventCallback) => void +} + +export interface VADAudioOptions { + /** + * Audio context options + */ + audioContextOptions?: AudioContextOptions + + /** + * The minimum size of audio chunks to process + */ + minChunkSize?: number + + /** + * VAD configuration options + */ + vadConfig?: Partial +} + +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, + } +} diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 2c969bffc..9cbb8a8e2 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5901adbb0..a8ad1d235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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