diff --git a/apps/realtime-audio/tsconfig.json b/apps/realtime-audio/tsconfig.json
index 4cdfa5b2f..65b580d32 100644
--- a/apps/realtime-audio/tsconfig.json
+++ b/apps/realtime-audio/tsconfig.json
@@ -23,10 +23,6 @@
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
- "src/**/*.mts",
- "playground/**/*.ts",
- "playground/**/*.d.ts",
- "playground/**/*.mts",
- "playground/**/*.vue"
+ "src/**/*.mts"
]
}
diff --git a/apps/vad/index.html b/apps/vad/index.html
new file mode 100644
index 000000000..42e6a40d9
--- /dev/null
+++ b/apps/vad/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Project AIRI VAD Playground
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/vad/locales/en.yml b/apps/vad/locales/en.yml
new file mode 100644
index 000000000..bf1d5461c
--- /dev/null
+++ b/apps/vad/locales/en.yml
@@ -0,0 +1 @@
+title: VAD
diff --git a/apps/vad/locales/zh-CN.yml b/apps/vad/locales/zh-CN.yml
new file mode 100644
index 000000000..bf1d5461c
--- /dev/null
+++ b/apps/vad/locales/zh-CN.yml
@@ -0,0 +1 @@
+title: VAD
diff --git a/apps/vad/netlify.toml b/apps/vad/netlify.toml
new file mode 100755
index 000000000..e26eeac5d
--- /dev/null
+++ b/apps/vad/netlify.toml
@@ -0,0 +1,13 @@
+[build]
+base = "/"
+command = "pnpm -F @proj-airi/vad... run build"
+publish = "/apps/vad/dist"
+
+[build.environment]
+NODE_VERSION = "23"
+
+[[redirects]]
+from = "/*"
+to = "/index.html"
+status = 200
+force = false
diff --git a/apps/vad/package.json b/apps/vad/package.json
new file mode 100644
index 000000000..49fa3a9ac
--- /dev/null
+++ b/apps/vad/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@proj-airi/vad",
+ "type": "module",
+ "private": true,
+ "description": "Voice Activity Detector",
+ "author": {
+ "name": "Neko Ayaka",
+ "email": "neko@ayaka.moe",
+ "url": "https://github.com/nekomeowww"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/moeru-ai/airi.git",
+ "directory": "apps/vad"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@huggingface/transformers": "^3.4.2",
+ "@vueuse/core": "^13.0.0",
+ "defu": "^6.1.4",
+ "es-toolkit": "^1.34.1",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@iconify-json/solar": "^1.2.2",
+ "@iconify-json/svg-spinners": "^1.2.2",
+ "@types/audioworklet": "^0.0.72",
+ "@unocss/reset": "^66.1.0-beta.10",
+ "@vitejs/plugin-vue": "^5.2.3",
+ "unplugin-vue-router": "^0.12.0",
+ "vite": "^6.2.5",
+ "vue-router": "^4.5.0",
+ "vue-tsc": "^3.0.0-alpha.2"
+ }
+}
diff --git a/apps/vad/public/favicon.ico b/apps/vad/public/favicon.ico
new file mode 100644
index 000000000..aeddd2905
Binary files /dev/null and b/apps/vad/public/favicon.ico differ
diff --git a/apps/vad/public/favicon.svg b/apps/vad/public/favicon.svg
new file mode 100644
index 000000000..a52f4ddeb
--- /dev/null
+++ b/apps/vad/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/vad/src/App.vue b/apps/vad/src/App.vue
new file mode 100644
index 000000000..61b6b87ef
--- /dev/null
+++ b/apps/vad/src/App.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ VAD Playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/vad/src/libs/vad/manager.ts b/apps/vad/src/libs/vad/manager.ts
new file mode 100644
index 000000000..4c5c20a91
--- /dev/null
+++ b/apps/vad/src/libs/vad/manager.ts
@@ -0,0 +1,160 @@
+// 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 startMicrophone(): 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 = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ sampleRate: this.audioContext.sampleRate,
+ },
+ })
+
+ // 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 = 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
+ }
+}
diff --git a/apps/vad/src/libs/vad/process.worklet.ts b/apps/vad/src/libs/vad/process.worklet.ts
new file mode 100644
index 000000000..899d933fe
--- /dev/null
+++ b/apps/vad/src/libs/vad/process.worklet.ts
@@ -0,0 +1,53 @@
+// vad-worklet-processor.ts
+// This file needs to be registered as an AudioWorklet
+
+/**
+ * Minimum chunk size for processing audio
+ */
+const MIN_CHUNK_SIZE = 512
+
+/**
+ * Global state for audio buffer accumulation
+ */
+let globalPointer = 0
+const globalBuffer = new Float32Array(MIN_CHUNK_SIZE)
+
+/**
+ * VAD AudioWorklet Processor - processes audio chunks and sends them to the main thread
+ */
+class VADProcessor extends AudioWorkletProcessor {
+ process(inputs: Float32Array[][], _outputs: Float32Array[][], _parameters: Record) {
+ const buffer = inputs[0][0]
+ if (!buffer)
+ return true // buffer is null when the stream ends
+
+ if (buffer.length > MIN_CHUNK_SIZE) {
+ // If the buffer is larger than the minimum chunk size, send the entire buffer
+ this.port.postMessage({ buffer })
+ }
+ else {
+ const remaining = MIN_CHUNK_SIZE - globalPointer
+ if (buffer.length >= remaining) {
+ // If the buffer is larger than (or equal to) the remaining space in the global buffer, copy the remaining space
+ globalBuffer.set(buffer.subarray(0, remaining), globalPointer)
+
+ // Send the global buffer
+ this.port.postMessage({ buffer: globalBuffer })
+
+ // Reset the global buffer and set the remaining buffer
+ globalBuffer.fill(0)
+ globalBuffer.set(buffer.subarray(remaining), 0)
+ globalPointer = buffer.length - remaining
+ }
+ else {
+ // If the buffer is smaller than the remaining space in the global buffer, copy the buffer to the global buffer
+ globalBuffer.set(buffer, globalPointer)
+ globalPointer += buffer.length
+ }
+ }
+
+ return true
+ }
+}
+
+registerProcessor('vad-processor', VADProcessor)
diff --git a/apps/vad/src/libs/vad/vad.ts b/apps/vad/src/libs/vad/vad.ts
new file mode 100644
index 000000000..bca783d93
--- /dev/null
+++ b/apps/vad/src/libs/vad/vad.ts
@@ -0,0 +1,319 @@
+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
+ private model: any
+ private state: Tensor
+ private sampleRateTensor: Tensor
+ private buffer: Float32Array
+ private bufferPointer: number = 0
+ private isRecording: boolean = false
+ private postSpeechSamples: number = 0
+ private prevBuffers: Float32Array[] = []
+ private inferenceChain: Promise = Promise.resolve()
+ private eventListeners: Partial[]>> = {}
+ private isReady: boolean = false
+
+ constructor(userConfig: Partial = {}) {
+ // Default configuration
+ const defaultConfig: VADConfig = {
+ sampleRate: 16000,
+ speechThreshold: 0.3,
+ exitThreshold: 0.1,
+ minSilenceDurationMs: 400,
+ speechPadMs: 80,
+ minSpeechDurationMs: 250,
+ maxBufferDuration: 30,
+ newBufferSize: 512,
+ }
+
+ this.config = { ...defaultConfig, ...userConfig }
+
+ // Create buffer based on max duration
+ this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
+
+ // Initialize state tensor for VAD model
+ this.state = new Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128])
+
+ // Sample rate tensor for the model
+ this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
+ }
+
+ /**
+ * Initialize the VAD model
+ */
+ public async initialize(): Promise {
+ try {
+ this.emit('status', { type: 'info', message: 'Loading VAD model...' })
+
+ this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', {
+ config: { model_type: 'custom' },
+ dtype: 'fp32', // Full-precision
+ })
+
+ this.isReady = true
+ this.emit('status', { type: 'info', message: 'VAD model loaded successfully' })
+ }
+ catch (error) {
+ this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` })
+ throw error
+ }
+ }
+
+ /**
+ * Add event listener
+ */
+ public on(event: K, callback: VADEventCallback): void {
+ if (!this.eventListeners[event]) {
+ this.eventListeners[event] = []
+ }
+ this.eventListeners[event]!.push(callback as any)
+ }
+
+ /**
+ * Remove event listener
+ */
+ public off(event: K, callback: VADEventCallback): void {
+ if (!this.eventListeners[event])
+ return
+ this.eventListeners[event] = this.eventListeners[event]!.filter(cb => cb !== callback)
+ }
+
+ /**
+ * Emit event
+ */
+ private emit(event: K, data: VADEvents[K]): void {
+ if (!this.eventListeners[event])
+ return
+ for (const callback of this.eventListeners[event]!) {
+ callback(data)
+ }
+ }
+
+ /**
+ * Process audio buffer for speech detection
+ */
+ public async processAudio(inputBuffer: Float32Array): Promise {
+ if (!this.isReady) {
+ throw new Error('VAD model is not initialized. Call initialize() first.')
+ }
+
+ const wasRecording = this.isRecording
+
+ // Perform VAD on the input buffer
+ const isSpeech = await this.detectSpeech(inputBuffer)
+
+ // Calculate derived constants
+ const sampleRateMs = this.config.sampleRate / 1000
+ const minSilenceDurationSamples = this.config.minSilenceDurationMs * sampleRateMs
+ const speechPadSamples = this.config.speechPadMs * sampleRateMs
+ const minSpeechDurationSamples = this.config.minSpeechDurationMs * sampleRateMs
+ const maxPrevBuffers = Math.ceil(speechPadSamples / this.config.newBufferSize)
+
+ // If not currently in speech and the current buffer isn't speech,
+ // store it in the previous buffers queue for potential padding
+ if (!wasRecording && !isSpeech) {
+ if (this.prevBuffers.length >= maxPrevBuffers) {
+ this.prevBuffers.shift()
+ }
+ this.prevBuffers.push(inputBuffer.slice(0))
+ return
+ }
+
+ // Check if we need to handle buffer overflow
+ const remaining = this.buffer.length - this.bufferPointer
+ if (inputBuffer.length >= remaining) {
+ // The buffer is full, process what we have
+ this.buffer.set(inputBuffer.subarray(0, remaining), this.bufferPointer)
+ this.bufferPointer += remaining
+
+ // Process and reset with overflow
+ const overflow = inputBuffer.subarray(remaining)
+ this.processSpeechSegment(overflow)
+ return
+ }
+ else {
+ // Add input to the buffer
+ this.buffer.set(inputBuffer, this.bufferPointer)
+ this.bufferPointer += inputBuffer.length
+ }
+
+ // Handle speech detection
+ if (isSpeech) {
+ if (!this.isRecording) {
+ // Speech just started
+ this.emit('speech-start', undefined)
+ this.emit('status', { type: 'info', message: 'Speech detected' })
+ }
+
+ // Update state
+ this.isRecording = true
+ this.postSpeechSamples = 0
+ return
+ }
+
+ // At this point, we were recording but the current buffer is not speech
+ this.postSpeechSamples += inputBuffer.length
+
+ // Check if silence is long enough to consider speech ended
+ if (this.postSpeechSamples >= minSilenceDurationSamples) {
+ // Check if the speech segment is long enough to process
+ if (this.bufferPointer < minSpeechDurationSamples) {
+ // Too short, reset without processing
+ this.reset()
+ return
+ }
+
+ // Process the speech segment
+ this.processSpeechSegment()
+ }
+ }
+
+ /**
+ * Detect speech in an audio buffer
+ */
+ private async detectSpeech(buffer: Float32Array): Promise {
+ const input = new Tensor('float32', buffer, [1, buffer.length])
+
+ const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() =>
+ this.model({
+ input,
+ sr: this.sampleRateTensor,
+ state: this.state,
+ }),
+ ))
+
+ // Update the state
+ this.state = stateN
+
+ // Get the speech probability
+ const speechProb = output.data[0]
+
+ this.emit('debug', {
+ message: 'VAD score',
+ data: { probability: speechProb },
+ })
+
+ // Apply thresholds
+ return (
+ speechProb > this.config.speechThreshold
+ || (this.isRecording && speechProb >= this.config.exitThreshold)
+ )
+ }
+
+ /**
+ * Process a complete speech segment
+ */
+ private processSpeechSegment(overflow?: Float32Array): void {
+ const sampleRateMs = this.config.sampleRate / 1000
+ const speechPadSamples = this.config.speechPadMs * sampleRateMs
+
+ // Calculate duration info
+ const duration = (this.bufferPointer / this.config.sampleRate) * 1000
+ const overflowLength = overflow?.length ?? 0
+
+ // Create the final buffer with padding
+ const prevLength = this.prevBuffers.reduce((acc, b) => acc + b.length, 0)
+ const finalBuffer = new Float32Array(prevLength + this.bufferPointer + speechPadSamples)
+
+ // Add previous buffers for pre-speech padding
+ let offset = 0
+ for (const prev of this.prevBuffers) {
+ finalBuffer.set(prev, offset)
+ offset += prev.length
+ }
+
+ // Add the main speech segment
+ finalBuffer.set(this.buffer.slice(0, this.bufferPointer + speechPadSamples), offset)
+
+ // Emit the speech segment
+ this.emit('speech-end', undefined)
+ this.emit('speech-ready', {
+ buffer: finalBuffer,
+ duration,
+ })
+
+ // Reset for the next segment
+ if (overflow) {
+ this.buffer.set(overflow, 0)
+ }
+ this.reset(overflowLength)
+ }
+
+ /**
+ * Reset the VAD state
+ */
+ private reset(offset: number = 0): void {
+ this.buffer.fill(0, offset)
+ this.bufferPointer = offset
+ this.isRecording = false
+ this.postSpeechSamples = 0
+ this.prevBuffers = []
+ }
+
+ /**
+ * Update configuration
+ */
+ public updateConfig(newConfig: Partial): void {
+ this.config = { ...this.config, ...newConfig }
+
+ // If buffer size changed, create a new buffer
+ if (newConfig.maxBufferDuration || newConfig.sampleRate) {
+ this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
+ this.bufferPointer = 0
+ }
+
+ // Update sample rate tensor if needed
+ if (newConfig.sampleRate) {
+ this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
+ }
+ }
+}
+
+/**
+ * Create a VAD processor with the given configuration
+ */
+export async function createVAD(config?: Partial): Promise {
+ const vad = new VAD(config)
+ await vad.initialize()
+ return vad
+}
diff --git a/apps/vad/src/libs/vad/wav.ts b/apps/vad/src/libs/vad/wav.ts
new file mode 100644
index 000000000..22cadbac9
--- /dev/null
+++ b/apps/vad/src/libs/vad/wav.ts
@@ -0,0 +1,43 @@
+function writeString(dataView: DataView, offset: number, string: string) {
+ for (let i = 0; i < string.length; i++) {
+ dataView.setUint8(offset + i, string.charCodeAt(i))
+ }
+}
+
+export function toWav(buffer: Float32Array, sampleRate: number) {
+ const numChannels = 1
+ const numSamples = buffer.length
+
+ // Create the WAV file container
+ const arrayBuffer = new ArrayBuffer(44 + numSamples * 2)
+ const dataView = new DataView(arrayBuffer)
+
+ // RIFF chunk descriptor
+ writeString(dataView, 0, 'RIFF')
+ dataView.setUint32(4, 36 + numSamples * 2, true)
+ writeString(dataView, 8, 'WAVE')
+
+ // fmt sub-chunk
+ writeString(dataView, 12, 'fmt ')
+ dataView.setUint32(16, 16, true)
+ dataView.setUint16(20, 1, true) // PCM format
+ dataView.setUint16(22, numChannels, true)
+ dataView.setUint32(24, sampleRate, true)
+ dataView.setUint32(28, sampleRate * numChannels * 2, true) // byte rate
+ dataView.setUint16(32, numChannels * 2, true) // block align
+ dataView.setUint16(34, 16, true) // bits per sample
+
+ // data sub-chunk
+ writeString(dataView, 36, 'data')
+ dataView.setUint32(40, numSamples * 2, true)
+
+ // Write the PCM samples
+ const offset = 44
+ for (let i = 0; i < numSamples; i++) {
+ const sample = Math.max(-1, Math.min(1, buffer[i]))
+ const value = sample < 0 ? sample * 0x8000 : sample * 0x7FFF
+ dataView.setInt16(offset + i * 2, value, true)
+ }
+
+ return arrayBuffer
+}
diff --git a/apps/vad/src/main.ts b/apps/vad/src/main.ts
new file mode 100644
index 000000000..52e83a7af
--- /dev/null
+++ b/apps/vad/src/main.ts
@@ -0,0 +1,13 @@
+import { createApp } from 'vue'
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { routes } from 'vue-router/auto-routes'
+
+import App from './App.vue'
+import '@unocss/reset/tailwind.css'
+import 'uno.css'
+
+const router = createRouter({ routes, history: createWebHashHistory() })
+
+createApp(App)
+ .use(router)
+ .mount('#app')
diff --git a/apps/vad/src/pages/index.vue b/apps/vad/src/pages/index.vue
new file mode 100644
index 000000000..bfae9587b
--- /dev/null
+++ b/apps/vad/src/pages/index.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
Voice Segments ({{ segments.length }})
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/vad/src/utils/loader.ts b/apps/vad/src/utils/loader.ts
new file mode 100644
index 000000000..5afb6cb09
--- /dev/null
+++ b/apps/vad/src/utils/loader.ts
@@ -0,0 +1,40 @@
+import type { WasmModule } from '../libs/vad'
+
+export async function loadWasmModule(wasmUrl: string): Promise {
+ return new Promise((resolve, reject) => {
+ // Create a script element to load the WASM module
+ const script = document.createElement('script')
+ script.src = wasmUrl
+ script.async = true
+
+ script.onload = () => {
+ // Access the global Module object
+ const Module = (window as any).Module
+
+ if (!Module) {
+ reject(new Error('Failed to load WASM module: Module not found'))
+ return
+ }
+
+ if (Module.onRuntimeInitialized) {
+ const originalOnRuntimeInitialized = Module.onRuntimeInitialized
+ Module.onRuntimeInitialized = () => {
+ if (originalOnRuntimeInitialized) {
+ originalOnRuntimeInitialized()
+ }
+ resolve(Module)
+ }
+ }
+ else {
+ // Module is already initialized
+ resolve(Module)
+ }
+ }
+
+ script.onerror = () => {
+ reject(new Error('Failed to load WASM module'))
+ }
+
+ document.head.appendChild(script)
+ })
+}
diff --git a/apps/vad/tsconfig.json b/apps/vad/tsconfig.json
new file mode 100644
index 000000000..75af8ab4f
--- /dev/null
+++ b/apps/vad/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "jsx": "preserve",
+ "lib": [
+ "DOM",
+ "ESNext",
+ "WebWorker"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "types": [
+ "vitest",
+ "vite/client",
+ // Currently AudioWorkletProcessor type is missing, we need to add it manually through @types/audioworklet
+ // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-1512509870
+ "@types/audioworklet"
+ ],
+ "allowJs": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "noUnusedLocals": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/apps/vad/uno.config.ts b/apps/vad/uno.config.ts
new file mode 100644
index 000000000..241b30eaa
--- /dev/null
+++ b/apps/vad/uno.config.ts
@@ -0,0 +1,37 @@
+import {
+ defineConfig,
+ presetAttributify,
+ presetIcons,
+ presetTypography,
+ presetWebFonts,
+ presetWind3,
+ transformerDirectives,
+ transformerVariantGroup,
+} from 'unocss'
+
+export default defineConfig({
+ presets: [
+ presetWind3(),
+ presetAttributify(),
+ presetTypography(),
+ presetWebFonts({
+ fonts: {
+ sans: 'DM Sans',
+ serif: 'DM Serif Display',
+ mono: 'DM Mono',
+ },
+ timeouts: {
+ warning: 5000,
+ failure: 10000,
+ },
+ }),
+ presetIcons({
+ scale: 1.2,
+ }),
+ ],
+ transformers: [
+ transformerDirectives(),
+ transformerVariantGroup(),
+ ],
+ safelist: 'prose prose-sm m-auto text-left'.split(' '),
+})
diff --git a/apps/vad/vite.config.ts b/apps/vad/vite.config.ts
new file mode 100644
index 000000000..5da74dfbe
--- /dev/null
+++ b/apps/vad/vite.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from 'node:path'
+import Vue from '@vitejs/plugin-vue'
+import Unocss from 'unocss/vite'
+import VueRouter from 'unplugin-vue-router/vite'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [
+ // https://github.com/posva/unplugin-vue-router
+ VueRouter({
+ extensions: ['.vue', '.md'],
+ dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
+ }),
+ Vue(),
+ // https://github.com/antfu/unocss
+ // see uno.config.ts for config
+ Unocss(),
+ ],
+})
diff --git a/packages/gpuu/src/webgpu/checker.ts b/packages/gpuu/src/webgpu/checker.ts
index 778e570e3..9a8fc17da 100644
--- a/packages/gpuu/src/webgpu/checker.ts
+++ b/packages/gpuu/src/webgpu/checker.ts
@@ -34,3 +34,7 @@ function isInNodejsRuntime() {
// eslint-disable-next-line node/prefer-global/process
&& 'node' in process.versions && process.versions.node != null
}
+
+export async function isWebGPUSupported() {
+ return check().then(result => result.supported)
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cc6711554..1bb6731e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -458,7 +458,7 @@ importers:
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
- version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.24.0(jiti@2.4.2))(rollup@4.39.0)(typescript@5.8.3)(vue-i18n@11.1.3(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
+ version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.24.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.3)(vue-i18n@11.1.3(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
'@proj-airi/lobe-icons':
specifier: ^1.0.5
version: 1.0.5
@@ -506,7 +506,7 @@ importers:
version: 3.0.0-beta.7(typescript@5.8.3)(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
'@vueuse/motion':
specifier: ^3.0.3
- version: 3.0.3(magicast@0.3.5)(rollup@4.39.0)(vue@3.5.13(typescript@5.8.3))
+ version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.3))
less:
specifier: ^4.3.0
version: 4.3.0
@@ -518,13 +518,13 @@ importers:
version: 3.2.0(unocss@66.1.0-beta.10(postcss@8.5.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3)))
unplugin-auto-import:
specifier: ^19.1.2
- version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))
+ version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))
unplugin-vue-components:
specifier: ^28.4.1
- version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(vue@3.5.13(typescript@5.8.3))
+ version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.3))
unplugin-vue-macros:
specifier: ^2.14.5
- version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.25.0)(rollup@4.39.0)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
+ version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -533,13 +533,13 @@ importers:
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
vite-bundle-visualizer:
specifier: ^1.2.1
- version: 1.2.1(rollup@4.39.0)
+ version: 1.2.1(rollup@2.79.1)
vite-plugin-pwa:
specifier: ^1.0.0
version: 1.0.0(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-vue-devtools:
specifier: ^7.7.2
- version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(rollup@4.39.0)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3))
+ version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(rollup@2.79.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
@@ -807,7 +807,7 @@ importers:
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
- version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.24.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.3)(vue-i18n@11.1.3(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
+ version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.24.0(jiti@2.4.2))(rollup@4.39.0)(typescript@5.8.3)(vue-i18n@11.1.3(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
'@proj-airi/lobe-icons':
specifier: ^1.0.5
version: 1.0.5
@@ -846,7 +846,7 @@ importers:
version: 3.0.0-beta.7(typescript@5.8.3)(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
'@vueuse/motion':
specifier: ^3.0.3
- version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.3))
+ version: 3.0.3(magicast@0.3.5)(rollup@4.39.0)(vue@3.5.13(typescript@5.8.3))
hfup:
specifier: ^0.5.0
version: 0.5.0(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -858,13 +858,13 @@ importers:
version: 4.0.1
unplugin-auto-import:
specifier: ^19.1.2
- version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))
+ version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))
unplugin-vue-components:
specifier: ^28.4.1
- version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.3))
+ version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(vue@3.5.13(typescript@5.8.3))
unplugin-vue-macros:
specifier: ^2.14.5
- version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
+ version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.25.0)(rollup@4.39.0)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -873,13 +873,13 @@ importers:
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
vite-bundle-visualizer:
specifier: ^1.2.1
- version: 1.2.1(rollup@2.79.1)
+ version: 1.2.1(rollup@4.39.0)
vite-plugin-pwa:
specifier: ^1.0.0
version: 1.0.0(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-vue-devtools:
specifier: ^7.7.2
- version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(rollup@2.79.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3))
+ version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.39.0))(rollup@4.39.0)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
@@ -887,6 +887,52 @@ importers:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2(typescript@5.8.3)
+ apps/vad:
+ dependencies:
+ '@huggingface/transformers':
+ specifier: ^3.4.2
+ version: 3.4.2
+ '@vueuse/core':
+ specifier: ^13.0.0
+ version: 13.0.0(vue@3.5.13(typescript@5.8.3))
+ defu:
+ specifier: ^6.1.4
+ version: 6.1.4
+ es-toolkit:
+ specifier: ^1.34.1
+ version: 1.34.1
+ vue:
+ specifier: ^3.5.13
+ version: 3.5.13(typescript@5.8.3)
+ devDependencies:
+ '@iconify-json/solar':
+ specifier: ^1.2.2
+ version: 1.2.2
+ '@iconify-json/svg-spinners':
+ specifier: ^1.2.2
+ version: 1.2.2
+ '@types/audioworklet':
+ specifier: ^0.0.72
+ version: 0.0.72
+ '@unocss/reset':
+ specifier: ^66.1.0-beta.10
+ version: 66.1.0-beta.10
+ '@vitejs/plugin-vue':
+ specifier: ^5.2.3
+ version: 5.2.3(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3))
+ unplugin-vue-router:
+ specifier: ^0.12.0
+ version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
+ vite:
+ specifier: ^6.2.5
+ version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
+ vue-router:
+ specifier: ^4.5.0
+ version: 4.5.0(vue@3.5.13(typescript@5.8.3))
+ vue-tsc:
+ specifier: ^3.0.0-alpha.2
+ version: 3.0.0-alpha.2(typescript@5.8.3)
+
docs:
devDependencies:
98.css:
@@ -4730,6 +4776,9 @@ packages:
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
+ '@types/audioworklet@0.0.72':
+ resolution: {integrity: sha512-qrsjMZxB0k5DJSLpcDXlX/OYa5iDEAc9dgEwCZO1op5jka7Jgc/fMEGq8LuHO+8MmpOInqco6hET1gawCTVeEg==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -15384,6 +15433,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.7
+ '@types/audioworklet@0.0.72': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.26.10
@@ -23667,9 +23718,9 @@ snapshots:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.39.0)
'@vueuse/core': 13.0.0(vue@3.5.13(typescript@5.8.3))
- unplugin-combine@1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
+ unplugin-combine@1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
optionalDependencies:
- esbuild: 0.19.12
+ esbuild: 0.25.0
rollup: 2.79.1
unplugin: 1.16.1
vite: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -23728,7 +23779,7 @@ snapshots:
transitivePeerDependencies:
- vue
- unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3)):
+ unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.3)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@vue-macros/better-define': 1.11.4(vue@3.5.13(typescript@5.8.3))
'@vue-macros/boolean-prop': 0.5.5(vue@3.5.13(typescript@5.8.3))
@@ -23760,7 +23811,7 @@ snapshots:
'@vue-macros/short-vmodel': 1.5.5(vue@3.5.13(typescript@5.8.3))
'@vue-macros/volar': 0.30.15(typescript@5.8.3)(vue-tsc@3.0.0-alpha.2(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))
unplugin: 1.16.1
- unplugin-combine: 1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
+ unplugin-combine: 1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.3.0)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-vue-define-options: 1.5.5(vue@3.5.13(typescript@5.8.3))
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies: