refactor: migrate out vad related examples

This commit is contained in:
Neko Ayaka
2025-04-16 11:28:08 +08:00
parent fdec031339
commit 9d2aef7014
59 changed files with 5 additions and 3725 deletions
-22
View File
@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Project AIRI VAD + ASR + LLM Chat 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
@@ -1 +0,0 @@
title: VAD
-1
View File
@@ -1 +0,0 @@
title: VAD
-13
View File
@@ -1,13 +0,0 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/vad-asr-chat... run build"
publish = "/apps/vad-asr-chat/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
-46
View File
@@ -1,46 +0,0 @@
{
"name": "@proj-airi/vad-asr-chat",
"type": "module",
"private": true,
"description": "Voice Activity Detector & Automatic Speech Recognition & Chat",
"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-asr-chat"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@huggingface/transformers": "^3.4.2",
"@llama-flow/core": "^0.3.4",
"@vueuse/core": "^13.0.0",
"@xsai/generate-transcription": "catalog:",
"@xsai/shared": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"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.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

-56
View File
@@ -1,56 +0,0 @@
<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 + ASR + LLM Chat 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-asr-chat">
<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>
@@ -1,39 +0,0 @@
<script lang="ts" setup>
import { watchEffect } from 'vue'
import TransitionVertical from './TransitionVertical.vue'
const props = defineProps<{
default?: boolean
label?: string
}>()
const visible = defineModel<boolean>({ default: false })
watchEffect(() => {
if (props.default != null) {
visible.value = !!props.default
}
})
function setVisible(value: boolean) {
visible.value = value
return value
}
</script>
<template>
<div>
<slot name="trigger" v-bind="{ visible, setVisible }">
<button
sticky top-0 z-10 flex items-center justify-between px2 py1 text-sm backdrop-blur-xl
@click="visible = !visible"
>
<span>
{{ props.label ?? 'Collapsable' }}
</span> <span op50>{{ visible ? '▲' : '▼' }}</span>
</button>
</slot>
<TransitionVertical>
<slot v-if="visible" v-bind="{ visible, setVisible }" />
</TransitionVertical>
</div>
</template>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import Input from './Input.vue'
const props = defineProps<{
label?: string
description?: string
placeholder?: string
required?: boolean
type?: string
inputClass?: string
}>()
const modelValue = defineModel<string>({ required: true })
</script>
<template>
<div max-w-full>
<label flex="~ col gap-2">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ props.label }}
<span v-if="props.required !== false" class="text-red-500">*</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400" text-nowrap>
{{ props.description }}
</div>
</div>
<Input
v-model="modelValue"
:type="props.type"
:placeholder="props.placeholder"
:class="props.inputClass"
/>
</label>
</div>
</template>
@@ -1,21 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
type?: string
}>()
const modelValue = defineModel<string>({ required: true })
</script>
<template>
<input
v-model="modelValue"
:type="props.type || 'text'"
border="focus:blue-300 dark:focus:blue-400/50 2 solid neutral-100 dark:neutral-900"
transition="all duration-200 ease-in-out"
text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed"
w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none
shadow="sm"
bg="neutral-50 dark:neutral-950 focus:neutral-50 dark:focus:neutral-900"
>
</template>
@@ -1,38 +0,0 @@
<script setup lang="ts">
import Collapsable from './Collapsable.vue'
withDefaults(defineProps<{
title: string
icon: string
innerClass?: string
expand?: boolean
}>(), {
expand: true,
})
</script>
<template>
<Collapsable :default="expand">
<template #trigger="slotProps">
<button
class="w-full flex items-center justify-between rounded-lg px-4 py-3 outline-none transition-all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex gap-1.5>
<div :class="icon" size-6 />
{{ title }}
</div>
<div
i-solar:alt-arrow-down-bold-duotone
transition="transform duration-250"
:class="{ 'rotate-180': slotProps.visible }"
/>
</button>
</template>
<div grid gap-4 p-4 :class="innerClass">
<slot />
</div>
</Collapsable>
</template>
@@ -1,134 +0,0 @@
<script setup lang="ts">
// From: https://stackoverflow.com/a/71426342/22392721
interface Props {
duration?: number
easingEnter?: string
easingLeave?: string
opacityClosed?: number
opacityOpened?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 250,
easingEnter: 'ease-in-out',
easingLeave: 'ease-in-out',
opacityClosed: 0,
opacityOpened: 1,
})
const closed = '0px'
interface initialStyle {
height: string
width: string
position: string
visibility: string
overflow: string
paddingTop: string
paddingBottom: string
borderTopWidth: string
borderBottomWidth: string
marginTop: string
marginBottom: string
}
function getElementStyle(element: HTMLElement) {
return {
height: element.style.height,
width: element.style.width,
position: element.style.position,
visibility: element.style.visibility,
overflow: element.style.overflow,
paddingTop: element.style.paddingTop,
paddingBottom: element.style.paddingBottom,
borderTopWidth: element.style.borderTopWidth,
borderBottomWidth: element.style.borderBottomWidth,
marginTop: element.style.marginTop,
marginBottom: element.style.marginBottom,
}
}
function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
const { width } = getComputedStyle(element)
element.style.width = width
element.style.position = 'absolute'
element.style.visibility = 'hidden'
element.style.height = ''
const { height } = getComputedStyle(element)
element.style.width = initialStyle.width
element.style.position = initialStyle.position
element.style.visibility = initialStyle.visibility
element.style.height = closed
element.style.overflow = 'hidden'
return initialStyle.height && initialStyle.height !== closed
? initialStyle.height
: height
}
function animateTransition(
element: HTMLElement,
initialStyle: initialStyle,
done: () => void,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: number | KeyframeAnimationOptions,
) {
const animation = element.animate(keyframes, options)
// Set height to 'auto' to restore it after animation
element.style.height = initialStyle.height
animation.onfinish = () => {
element.style.overflow = initialStyle.overflow
done()
}
}
function getEnterKeyframes(height: string, initialStyle: initialStyle) {
return [
{
height: closed,
opacity: props.opacityClosed,
paddingTop: closed,
paddingBottom: closed,
borderTopWidth: closed,
borderBottomWidth: closed,
marginTop: closed,
marginBottom: closed,
},
{
height,
opacity: props.opacityOpened,
paddingTop: initialStyle.paddingTop,
paddingBottom: initialStyle.paddingBottom,
borderTopWidth: initialStyle.borderTopWidth,
borderBottomWidth: initialStyle.borderBottomWidth,
marginTop: initialStyle.marginTop,
marginBottom: initialStyle.marginBottom,
},
]
}
function enterTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement
const initialStyle = getElementStyle(HTMLElement)
const height = prepareElement(HTMLElement, initialStyle)
const keyframes = getEnterKeyframes(height, initialStyle)
const options = { duration: props.duration, easing: props.easingEnter }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
function leaveTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement
const initialStyle = getElementStyle(HTMLElement)
const { height } = getComputedStyle(HTMLElement)
HTMLElement.style.height = height
HTMLElement.style.overflow = 'hidden'
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
const options = { duration: props.duration, easing: props.easingLeave }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
</script>
<template>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<slot />
</Transition>
</template>
-175
View File
@@ -1,175 +0,0 @@
// 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
}
}
public async stopMicrophone(): Promise<void> {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
if (this.sourceNode) {
this.sourceNode.disconnect()
this.sourceNode = null
}
this.audioContext?.suspend()
this.audioWorkletNode?.disconnect()
}
/**
* 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
}
}
@@ -1,53 +0,0 @@
// 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
@@ -1,319 +0,0 @@
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' } as any,
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
@@ -1,43 +0,0 @@
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
@@ -1,13 +0,0 @@
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')
-324
View File
@@ -1,324 +0,0 @@
<script setup lang="ts">
import type { Message } from '@xsai/shared-chat'
import { createWorkflow, getContext, workflowEvent } from '@llama-flow/core'
import { useLocalStorage } from '@vueuse/core'
import { generateTranscription } from '@xsai/generate-transcription'
import { streamText } from '@xsai/stream-text'
import { ref, toRaw } from 'vue'
import FieldInput from '../components/FieldInput.vue'
import Section from '../components/Section.vue'
import { VADAudioManager } from '../libs/vad/manager'
import workletUrl from '../libs/vad/process.worklet?worker&url'
import { createVAD } from '../libs/vad/vad'
import { toWav } from '../libs/vad/wav'
interface AudioSegment {
buffer: Float32Array
duration: number
timestamp: number
audioSrc: string
transcription: string
}
const llmInputSpeechEvent = workflowEvent<{ buffer: Float32Array<ArrayBufferLike>, duration: number }, 'input-speech'>()
const llmTranscriptionEvent = workflowEvent<string, 'transcription'>()
const llmChatCompletionsTokenEvent = workflowEvent<string, 'chat-completions-token'>()
const llmChatCompletionsEndedEvent = workflowEvent<void, 'chat-completions-ended'>()
const llmProviderBaseURL = useLocalStorage('llmProviderBaseURL', 'https://openrouter.ai/api/v1/')
const llmProviderAPIKey = useLocalStorage('llmProviderAPIKey', '')
const llmProviderModel = useLocalStorage('llmProviderModel', 'gpt-4o-mini')
const asrProviderBaseURL = useLocalStorage('asrProviderBaseURL', 'http://localhost:8000/v1/')
const asrProviderAPIKey = useLocalStorage('asrProviderAPIKey', '')
const asrProviderModel = useLocalStorage('asrProviderModel', 'large-v3-turbo')
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)
const sending = ref(false)
const messages = ref<Message[]>([
{
role: 'system',
content: 'You are having a phone call with a user, the texts are all transcribed from the audio, it may not be accurate, if you cannot understand what user said, please ask them to repeat it.',
},
])
const streamingMessage = ref<Message>({ role: 'assistant', content: '' })
const llmWorkflow = createWorkflow()
const llmWorkflowContext = llmWorkflow.createContext()
async function* asyncIteratorFromReadableStream<T, F = Uint8Array>(res: ReadableStream<F>, func: (value: F) => Promise<T>): AsyncGenerator<T, void, unknown> {
// react js - TS2504: Type 'ReadableStream<Uint8Array>' must have a '[Symbol.asyncIterator]()' method that returns an async iterator - Stack Overflow
// https://stackoverflow.com/questions/76700924/ts2504-type-readablestreamuint8array-must-have-a-symbol-asynciterator
const reader = res.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return
}
yield func(value)
}
}
finally {
reader.releaseLock()
}
}
llmWorkflow.handle([llmInputSpeechEvent], async (event) => {
const context = getContext()
const wavBuffer = toWav(event.data.buffer, 16000)
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' })
const obj = {
buffer: event.data.buffer,
duration: event.data.duration / 1000, // Convert to seconds for display
timestamp: Date.now(),
audioSrc: URL.createObjectURL(audioBlob),
transcription: '',
}
// Store the segment
const objIndex = segments.value.push(obj)
generateTranscription({
baseURL: asrProviderBaseURL.value,
file: audioBlob,
model: asrProviderModel.value,
apiKey: asrProviderAPIKey.value,
}).then((res) => {
segments.value[objIndex - 1].transcription = res.text
context.sendEvent(llmTranscriptionEvent.with(res.text))
}).catch((err) => {
console.error('Failed to generate transcription:', err)
})
})
llmWorkflow.handle([llmTranscriptionEvent], async (event) => {
const context = getContext()
try {
sending.value = true
if (!event.data)
return
streamingMessage.value = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: event.data })
messages.value.push(streamingMessage.value)
const newMessages = messages.value.slice(0, messages.value.length - 1).map(msg => toRaw(msg))
const res = await streamText({
baseURL: llmProviderBaseURL.value,
apiKey: llmProviderAPIKey.value,
model: llmProviderModel.value,
messages: newMessages as Message[],
})
for await (const textPart of asyncIteratorFromReadableStream(res.textStream, async v => v)) {
context.sendEvent(llmChatCompletionsTokenEvent.with(textPart))
}
context.sendEvent(llmChatCompletionsEndedEvent.with())
}
catch (error) {
console.error('Error sending message:', error)
throw error
}
finally {
sending.value = false
}
})
llmWorkflow.handle([llmChatCompletionsTokenEvent], async (event) => {
if (!streamingMessage.value.content) {
streamingMessage.value.content = ''
}
streamingMessage.value.content += event.data as any
})
llmWorkflow.handle([llmChatCompletionsEndedEvent], async () => {
// eslint-disable-next-line no-console
console.log('llmChatCompletionsEndedEvent')
})
async function setupSpeechDetection() {
messages.value = [
messages.value[0],
]
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 }) => {
llmWorkflowContext.sendEvent(llmInputSpeechEvent.with({ buffer, duration }))
})
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?.stop()
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)
}
}
async function stopVad() {
await audioManager.value?.stopMicrophone()
isRunning.value = false
isSpeechDetected.value = false
}
function toggleListening() {
if (isRunning.value) {
stopVad()
}
else {
startVad()
}
}
</script>
<template>
<div mb-6 mt-4 h-full w-full flex flex-col gap-2>
<div w-full flex flex-1 flex-col gap-2>
<Section title="Settings" icon="i-solar:settings-bold" :expand="!isInitialized">
<div flex="~ col gap-4">
<FieldInput v-model="llmProviderBaseURL" label="LLM Provider Base URL" description="The base URL of the LLM provider. Generally, Speaches is recommended." />
<FieldInput v-model="llmProviderAPIKey" label="LLM Provider API Key" description="The API key of the LLM provider" type="password" />
<FieldInput v-model="llmProviderModel" label="LLM Provider Model" description="The model to use for the LLM provider" />
<FieldInput v-model="asrProviderBaseURL" label="ASR Provider Base URL" description="The base URL of the ASR provider. Generally, Speaches is recommended." />
<FieldInput v-model="asrProviderAPIKey" label="ASR Provider API Key" description="The API key of the ASR provider" type="password" />
<FieldInput v-model="asrProviderModel" label="ASR Provider Model" description="The model to use for the ASR provider" />
</div>
</Section>
<Section title="Voice Segments" icon="i-solar:microphone-3-bold" :expand="false">
<ul v-if="segments?.length && segments.length > 0">
<li v-for="(segment, index) in segments" :key="index" class="segment" flex flex-col gap-2>
<div class="segment-info" grid="~ cols-[120px_1fr] gap-2">
<span text="neutral-400 dark:neutral-500">Duration</span>
<span font-mono>
{{ segment.duration.toFixed(2) }}s
</span>
<span text="neutral-400 dark:neutral-500">Transcription</span>
<span>
{{ segment.transcription }}
</span>
</div>
<audio :src="segment.audioSrc" controls w-full />
</li>
</ul>
</Section>
<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 my-4 max-h-120 w-full flex flex-col gap-2 overflow-y-scroll>
<template v-for="(message, index) in messages" :key="index">
<div v-if="message.role === 'user'" class="w-fit rounded-lg bg-cyan-100 px-3 py-2 dark:bg-cyan-900">
{{ message.content }}
</div>
<div v-else-if="message.role === 'assistant'" class="w-fit rounded-lg bg-neutral-100 px-3 py-2 dark:bg-neutral-800">
{{ message.content }}
</div>
</template>
</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-700 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>
-41
View File
@@ -1,41 +0,0 @@
{
"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",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.js"
],
"exclude": [
"dist",
"node_modules"
]
}
-37
View File
@@ -1,37 +0,0 @@
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
@@ -1,19 +0,0 @@
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(),
],
})
-22
View File
@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Project AIRI VAD + ASR 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
@@ -1 +0,0 @@
title: VAD
-1
View File
@@ -1 +0,0 @@
title: VAD
-13
View File
@@ -1,13 +0,0 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/vad-asr... run build"
publish = "/apps/vad-asr/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
-42
View File
@@ -1,42 +0,0 @@
{
"name": "@proj-airi/vad-asr",
"type": "module",
"private": true,
"description": "Voice Activity Detector & Automatic Speech Recognition",
"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-asr"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@huggingface/transformers": "^3.4.2",
"@vueuse/core": "^13.0.0",
"@xsai/generate-transcription": "catalog:",
"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.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

-56
View File
@@ -1,56 +0,0 @@
<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 + ASR 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-asr">
<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>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import Input from './Input.vue'
const props = defineProps<{
label?: string
description?: string
placeholder?: string
required?: boolean
type?: string
inputClass?: string
}>()
const modelValue = defineModel<string>({ required: true })
</script>
<template>
<div max-w-full>
<label flex="~ col gap-2">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ props.label }}
<span v-if="props.required !== false" class="text-red-500">*</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400" text-nowrap>
{{ props.description }}
</div>
</div>
<Input
v-model="modelValue"
:type="props.type"
:placeholder="props.placeholder"
:class="props.inputClass"
/>
</label>
</div>
</template>
-21
View File
@@ -1,21 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
type?: string
}>()
const modelValue = defineModel<string>({ required: true })
</script>
<template>
<input
v-model="modelValue"
:type="props.type || 'text'"
border="focus:blue-300 dark:focus:blue-400/50 2 solid neutral-100 dark:neutral-900"
transition="all duration-200 ease-in-out"
text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed"
w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none
shadow="sm"
bg="neutral-50 dark:neutral-950 focus:neutral-50 dark:focus:neutral-900"
>
</template>
-175
View File
@@ -1,175 +0,0 @@
// 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
}
}
public async stopMicrophone(): Promise<void> {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
if (this.sourceNode) {
this.sourceNode.disconnect()
this.sourceNode = null
}
this.audioContext?.suspend()
this.audioWorkletNode?.disconnect()
}
/**
* 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
}
}
@@ -1,53 +0,0 @@
// 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
@@ -1,319 +0,0 @@
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' } as any,
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
@@ -1,43 +0,0 @@
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
@@ -1,13 +0,0 @@
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')
-205
View File
@@ -1,205 +0,0 @@
<script setup lang="ts">
import { generateTranscription } from '@xsai/generate-transcription'
import { ref } from 'vue'
import FieldInput from '../components/FieldInput.vue'
import { VADAudioManager } from '../libs/vad/manager'
import workletUrl from '../libs/vad/process.worklet?worker&url'
import { createVAD } from '../libs/vad/vad'
import { toWav } from '../libs/vad/wav'
interface AudioSegment {
buffer: Float32Array
duration: number
timestamp: number
audioSrc: string
transcription: string
}
const asrProviderBaseURL = ref('http://localhost:8000/v1/')
const asrProviderAPIKey = ref('')
const asrProviderModel = ref('large-v3-turbo')
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' })
const obj = {
buffer,
duration: duration / 1000, // Convert to seconds for display
timestamp: Date.now(),
audioSrc: URL.createObjectURL(audioBlob),
transcription: '',
}
// Store the segment
const objIndex = segments.value.push(obj)
generateTranscription({
baseURL: asrProviderBaseURL.value,
file: audioBlob,
model: asrProviderModel.value,
apiKey: asrProviderAPIKey.value,
}).then((res) => {
segments.value[objIndex - 1].transcription = res.text
}).catch((err) => {
console.error('Failed to generate transcription:', err)
})
})
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?.stop()
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)
}
}
async function stopVad() {
await audioManager.value?.stopMicrophone()
isRunning.value = false
isSpeechDetected.value = false
}
function toggleListening() {
if (isRunning.value) {
stopVad()
}
else {
startVad()
}
}
</script>
<template>
<div mb-6 mt-4 h-full w-full flex flex-col gap-2>
<div w-full flex-1>
<div v-if="!isRunning">
<div flex="~ col gap-4">
<FieldInput v-model="asrProviderBaseURL" label="ASR Provider Base URL" description="The base URL of the ASR provider. Generally, Speaches is recommended." />
<FieldInput v-model="asrProviderAPIKey" label="ASR Provider API Key" description="The API key of the ASR provider" />
<FieldInput v-model="asrProviderModel" label="ASR Provider Model" description="The model to use for the ASR provider" />
</div>
</div>
<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" my-4 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" grid="~ cols-[120px_1fr] gap-2">
<span text="neutral-400 dark:neutral-500">Duration</span>
<span font-mono>
{{ segment.duration.toFixed(2) }}s
</span>
<span text="neutral-400 dark:neutral-500">Transcription</span>
<span>
{{ segment.transcription }}
</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-700 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>
-41
View File
@@ -1,41 +0,0 @@
{
"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",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.js"
],
"exclude": [
"dist",
"node_modules"
]
}
-37
View File
@@ -1,37 +0,0 @@
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
@@ -1,19 +0,0 @@
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(),
],
})
-22
View File
@@ -1,22 +0,0 @@
<!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
@@ -1 +0,0 @@
title: VAD
-1
View File
@@ -1 +0,0 @@
title: VAD
-13
View File
@@ -1,13 +0,0 @@
[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
@@ -1,41 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

-56
View File
@@ -1,56 +0,0 @@
<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>
-175
View File
@@ -1,175 +0,0 @@
// 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
}
}
public async stopMicrophone(): Promise<void> {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
if (this.sourceNode) {
this.sourceNode.disconnect()
this.sourceNode = null
}
this.audioContext?.suspend()
this.audioWorkletNode?.disconnect()
}
/**
* 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
@@ -1,53 +0,0 @@
// 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
@@ -1,319 +0,0 @@
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' } as any,
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
@@ -1,43 +0,0 @@
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
@@ -1,13 +0,0 @@
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')
-169
View File
@@ -1,169 +0,0 @@
<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?.stop()
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)
}
}
async function stopVad() {
await audioManager.value?.stopMicrophone()
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>
-41
View File
@@ -1,41 +0,0 @@
{
"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",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.js"
],
"exclude": [
"dist",
"node_modules"
]
}
-37
View File
@@ -1,37 +0,0 @@
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
@@ -1,19 +0,0 @@
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(),
],
})
+5 -191
View File
@@ -524,7 +524,7 @@ importers:
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@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.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-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))
@@ -887,162 +887,6 @@ 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)
apps/vad-asr:
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))
'@xsai/generate-transcription':
specifier: 'catalog:'
version: 0.2.0-beta.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)
apps/vad-asr-chat:
dependencies:
'@huggingface/transformers':
specifier: ^3.4.2
version: 3.4.2
'@llama-flow/core':
specifier: ^0.3.4
version: 0.3.4(@modelcontextprotocol/sdk@1.8.0)(zod@3.24.2)
'@vueuse/core':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.3))
'@xsai/generate-transcription':
specifier: 'catalog:'
version: 0.2.0-beta.3
'@xsai/shared':
specifier: 'catalog:'
version: 0.2.0-beta.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.2.0-beta.3
'@xsai/stream-text':
specifier: 'catalog:'
version: 0.2.0-beta.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:
@@ -3765,26 +3609,6 @@ packages:
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@llama-flow/core@0.3.4':
resolution: {integrity: sha512-BOe23pfm7j9hKMH7u0jFS8bPKLsShKgz8KdN/rXeicgnopCwhDMO4ppOg7Cy7tWap8kYZIY8ZliN7Q9SmNfjkg==}
peerDependencies:
'@modelcontextprotocol/sdk': ^1.7.0
hono: ^4.7.4
next: ^15.2.2
p-retry: ^6.2.1
zod: ^3.24.2
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
hono:
optional: true
next:
optional: true
p-retry:
optional: true
zod:
optional: true
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -4909,9 +4733,6 @@ 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==}
@@ -14331,11 +14152,6 @@ snapshots:
dependencies:
'@lezer/common': 1.2.3
'@llama-flow/core@0.3.4(@modelcontextprotocol/sdk@1.8.0)(zod@3.24.2)':
optionalDependencies:
'@modelcontextprotocol/sdk': 1.8.0
zod: 3.24.2
'@marijn/find-cluster-break@1.0.2': {}
'@mdit-vue/plugin-component@2.1.3':
@@ -15571,8 +15387,6 @@ snapshots:
dependencies:
'@types/estree': 1.0.7
'@types/audioworklet@0.0.72': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.26.10
@@ -23856,9 +23670,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.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-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)):
optionalDependencies:
esbuild: 0.25.0
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)
@@ -23917,7 +23731,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.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-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)):
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))
@@ -23949,7 +23763,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.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-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-vue-define-options: 1.5.5(vue@3.5.13(typescript@5.8.3))
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies: