feat(vad): new module vad

This commit is contained in:
Neko Ayaka
2025-04-13 17:52:28 +08:00
parent 800b8fb0f3
commit f9cf0d4640
21 changed files with 1092 additions and 23 deletions
+1 -5
View File
@@ -23,10 +23,6 @@
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.mts",
"playground/**/*.ts",
"playground/**/*.d.ts",
"playground/**/*.mts",
"playground/**/*.vue"
"src/**/*.mts"
]
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Project AIRI VAD Playground</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
title: VAD
+1
View File
@@ -0,0 +1 @@
title: VAD
+13
View File
@@ -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
+41
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
import { RouterLink, RouterView } from 'vue-router'
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
<template>
<div mx-auto h-full max-w-screen-lg flex flex-col gap-2 p-4>
<header flex flex-row items-center justify-between>
<h1 text-2xl>
VAD Playground
</h1>
<div flex flex-row items-center gap-2>
<button text-lg @click="() => toggleDark()">
<div v-if="isDark" i-solar:moon-stars-bold-duotone />
<div v-else i-solar:sun-bold />
</button>
<a href="https://github.com/moeru-ai/airi/tree/main/apps/vad">
<div i-simple-icons:github />
</a>
</div>
</header>
<nav bg="neutral-100 dark:neutral-800" w-fit flex items-center of-hidden rounded-lg>
<RouterLink
to="/" px-3 py-2 bg="hover:neutral-200 dark:hover:neutral-700"
transition="all duration-250 ease-in-out"
>
<h1>WASM</h1>
</RouterLink>
</nav>
<RouterView />
</div>
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
background: #fff;
transition: all 0.3s ease-in-out;
}
html.dark {
background: #121212;
color-scheme: dark;
}
</style>
+160
View File
@@ -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<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 startMicrophone(): 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 = 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
}
}
+53
View File
@@ -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<string, Float32Array>) {
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)
+319
View File
@@ -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<K extends keyof VADEvents> =
(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<any> = Promise.resolve()
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
private isReady: boolean = false
constructor(userConfig: Partial<VADConfig> = {}) {
// 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<void> {
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<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = []
}
this.eventListeners[event]!.push(callback as any)
}
/**
* Remove event listener
*/
public off<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void {
if (!this.eventListeners[event])
return
this.eventListeners[event] = this.eventListeners[event]!.filter(cb => cb !== callback)
}
/**
* Emit event
*/
private emit<K extends keyof VADEvents>(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<void> {
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<boolean> {
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<VADConfig>): 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<VADConfig>): Promise<VAD> {
const vad = new VAD(config)
await vad.initialize()
return vad
}
+43
View File
@@ -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
}
+13
View File
@@ -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')
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref } from 'vue'
import { VADAudioManager } from '../libs/vad/manager'
import workletUrl from '../libs/vad/process.worklet?url'
import { createVAD } from '../libs/vad/vad'
import { toWav } from '../libs/vad/wav'
interface AudioSegment {
buffer: Float32Array
duration: number
timestamp: number
audioSrc: string
}
const audioManager = ref<VADAudioManager>()
const isInitialized = ref(false)
const isRunning = ref(false)
const isSpeechDetected = ref(false)
const segments = ref<AudioSegment[]>([])
const error = ref<string | null>(null)
const isModuleLoading = ref(false)
async function setupSpeechDetection() {
try {
isModuleLoading.value = true
// Create and initialize the VAD
const vad = await createVAD({
sampleRate: 16000,
speechThreshold: 0.3,
exitThreshold: 0.1,
minSilenceDurationMs: 400,
})
// Set up event handlers
vad.on('speech-start', () => {
isSpeechDetected.value = true
})
vad.on('speech-end', () => {
isSpeechDetected.value = false
})
vad.on('speech-ready', async ({ buffer, duration }) => {
const wavBuffer = toWav(buffer, 16000)
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' })
// Store the segment
segments.value.push({
buffer,
duration: duration / 1000, // Convert to seconds for display
timestamp: Date.now(),
audioSrc: URL.createObjectURL(audioBlob),
})
})
vad.on('status', ({ type, message }) => {
if (type === 'error') {
error.value = message
}
})
// Create and initialize audio manager
const m = new VADAudioManager(vad, {
minChunkSize: 512,
audioContextOptions: {
sampleRate: 16000,
latencyHint: 'interactive',
},
})
await m.initialize(workletUrl)
audioManager.value = m
isInitialized.value = true
isModuleLoading.value = false
startVad()
}
catch (err) {
console.error('Setup failed:', err)
error.value = err instanceof Error ? err.message : String(err)
isModuleLoading.value = false
}
}
async function destroySpeechDetection() {
await audioManager.value?.dispose()
isInitialized.value = false
isRunning.value = false
isSpeechDetected.value = false
for (const segment of segments.value) {
URL.revokeObjectURL(segment.audioSrc)
}
segments.value = []
error.value = null
isModuleLoading.value = false
}
async function startVad() {
try {
await audioManager.value?.startMicrophone()
isRunning.value = true
error.value = null
}
catch (err) {
console.error('Failed to start microphone:', err)
error.value = err instanceof Error ? err.message : String(err)
}
}
function stopVad() {
audioManager.value?.stop()
isRunning.value = false
isSpeechDetected.value = false
}
function toggleListening() {
if (isRunning.value) {
stopVad()
}
else {
startVad()
}
}
</script>
<template>
<div mb-6 h-full w-full flex flex-col gap-2>
<div w-full flex-1>
<div v-if="isModuleLoading" mt-20 flex items-center justify-center text-5xl>
<div i-svg-spinners:3-dots-move />
</div>
<div v-if="error" class="error">
{{ error }}
</div>
<div v-if="segments?.length && segments.length > 0" class="segments" w-full flex flex-col gap-2>
<h3>Voice Segments ({{ segments.length }})</h3>
<ul>
<li v-for="(segment, index) in segments" :key="index" class="segment" flex flex-col gap-2>
<div class="segment-info">
<span>Duration: {{ segment.duration.toFixed(2) }}s</span>
</div>
<audio :src="segment.audioSrc" controls w-full />
</li>
</ul>
</div>
</div>
<div w-full flex justify-center gap-4>
<button aspect-square size-15 flex items-center justify-center rounded-full text-2xl :class="[isRunning ? 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900' : 'bg-neutral-900 dark:bg-white/20 text-white dark:text-white', isInitialized ? 'opacity-100' : 'opacity-0']" :disabled="!isInitialized" @click="toggleListening">
<div i-solar:microphone-3-bold />
</button>
<button v-if="!isInitialized" bg="green-500 dark:green-500 hover:green-400 dark:hover:green-400 active:green-500 dark:active:green-500" transition="all duration-250 ease-in-out" aspect-square size-15 flex items-center justify-center rounded-full @click="setupSpeechDetection">
<div i-solar:phone-rounded-bold text-4xl text-white />
</button>
<button v-else bg="red-500 dark:red-500 hover:red-400 dark:hover:red-400 active:red-500 dark:active:red-500" transition="all duration-250 ease-in-out" aspect-square size-15 flex items-center justify-center rounded-full text-4xl text-white @click="destroySpeechDetection">
<div i-solar:end-call-rounded-bold />
</button>
<button aspect-square size-15 flex items-center justify-center rounded-full text-2xl class="bg-neutral-900 text-white dark:bg-neutral-900 dark:bg-white/20 dark:text-white" :class="isInitialized ? 'opacity-100' : 'opacity-0'" :disabled="!isInitialized">
<div i-solar:headphones-round-bold />
</button>
</div>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
import type { WasmModule } from '../libs/vad'
export async function loadWasmModule(wasmUrl: string): Promise<WasmModule> {
return new Promise<WasmModule>((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)
})
}
+31
View File
@@ -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"]
}
+37
View File
@@ -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(' '),
})
+19
View File
@@ -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(),
],
})
+4
View File
@@ -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)
}
+69 -18
View File
@@ -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: