feat: moonshine-web demo

This commit is contained in:
Neko Ayaka
2024-12-25 15:20:13 +08:00
parent 70f4965a8b
commit 86ecb9439b
28 changed files with 1318 additions and 26 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine as build-stage
WORKDIR /app
RUN corepack enable
COPY .npmrc package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+71
View File
@@ -0,0 +1,71 @@
---
title: Moonshine Web (Vue)
emoji: 🌙
colorFrom: blue
colorTo: pink
sdk: static
pinned: false
license: apache-2.0
models:
- onnx-community/moonshine-base-ONNX
short_description: Yet another Real-time in-browser speech recognition, re-implemented with Vue
thumbnail: https://raw.githubusercontent.com/moeru-ai/airi/refs/heads/main/packages/whisper-webgpu/public/banner.png
---
<h1 align="center">Moonshine Web (Vue)</h1>
<p align="center">
[<a href="https://moonshine-web-vue.netlify.app/">Try it</a>]
</p>
> Heavily inspired by [Realtime in-browser speech recognition](https://huggingface.co/spaces/webml-community/moonshine-web)
# Moonshine Web
A simple Vue + Vite application for running [Moonshine Base](https://huggingface.co/onnx-community/moonshine-base-ONNX), a powerful speech-to-text model optimized for fast and accurate automatic speech recognition (ASR) on resource-constrained devices. It runs locally in the browser using Transformers.js and WebGPU-acceleration (or WASM as a fallback).
## Getting Started
Follow the steps below to set up and run the application.
### 1. Clone the Repository
Clone the examples repository from GitHub:
```sh
git clone https://github.com/moeru-ai/airi.git
```
### 2. Navigate to the Project Directory
Change your working directory to the `moonshine-web` folder:
```sh
cd packages/moonshine-web
```
### 3. Install Dependencies
Install the necessary dependencies using npm:
```sh
npm i
```
### 4. Run the Development Server
Start the development server:
```sh
npm run dev
```
The application should now be running locally. Open your browser and go to `http://localhost:5175` to see it in action.
## Acknowledgements
The audio visualizer was adapted from Wael Yasmina's [amazing tutorial](https://waelyasmina.net/articles/how-to-create-a-3d-audio-visualizer-using-three-js/).
Great thanks to what Xenova have done.
> [Source code](https://github.com/huggingface/transformers.js-examples/tree/38a883dd465d70d7368b86b95aa0678895ca4e83/moonshine-web)
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Moonshine Web (Vue)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/png" href="/logo.png" />
<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">
<script id="vertexshader" type="vertex">
uniform float u_time;
vec3 mod289(vec3 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 mod289(vec4 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.0)+10.0)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
vec3 fade(vec3 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise, periodic variant
float pnoise(vec3 P, vec3 rep)
{
vec3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
vec3 Pi1 = mod(Pi0 + vec3(1.0), rep); // Integer part + 1, mod period
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 * (1.0 / 7.0);
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 * (1.0 / 7.0);
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}
uniform float u_frequency;
void main() {
float noise = 3.0 * pnoise(position + u_time, vec3(10.0));
float displacement = (u_frequency / 30.) * (noise / 10.);
vec3 newPosition = position + normal * displacement;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
</script>
<script id="fragmentshader" type="fragment">
uniform float u_red;
uniform float u_blue;
uniform float u_green;
void main() {
gl_FragColor = vec4(vec3(u_red, u_green, u_blue), 1. );
}
</script>
<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>
+18
View File
@@ -0,0 +1,18 @@
[build]
publish = "packages/moonshine-web/dist"
command = "pnpm run packages:stub && pnpm run build"
[build.environment]
NODE_VERSION = "22"
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@proj-airi/moonshine-web",
"type": "module",
"private": true,
"packageManager": "pnpm@9.15.1",
"description": "Yet another WebGPU based STT + VAD with Moonshine model re-implemented",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"scripts": {
"build": "vite build",
"dev": "vite --port 5175",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@tresjs/core": "^4.3.1",
"@unocss/reset": "^0.65.2",
"@vueuse/core": "^12.1.0",
"@vueuse/motion": "^2.2.6",
"ofetch": "^1.4.1",
"three": "^0.171.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@huggingface/transformers": "^3.2.1",
"@types/audioworklet": "^0.0.65",
"@types/three": "^0.171.0",
"@vitejs/plugin-vue": "^5.2.1",
"@webgpu/types": "^0.1.52",
"vue-tsc": "^2.1.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
import type { MessageEvent, MessageEventInfo, MessageEventOutput, MessageEventStatus } from './libs/types'
import { TresCanvas } from '@tresjs/core'
import { useWebWorker } from '@vueuse/core'
import { ACESFilmicToneMapping, SRGBColorSpace } from 'three'
import { onMounted, ref, watch } from 'vue'
import AnimatedMesh from './components/AnimatedMesh.vue'
import BloomScene from './components/BloomScene.vue'
import { SAMPLE_RATE } from './constants'
import ProcessorWorklet from './libs/processor?worker&url'
import { MessageType } from './libs/types'
import Worker from './libs/worker?worker&url'
import { formatDate } from './utils'
const status = ref<string | null>(null)
const error = ref(null)
const messages = ref<Array<MessageEventStatus | MessageEventInfo | MessageEventOutput>>([])
const frequency = ref(0)
const { post, data } = useWebWorker<MessageEvent>(Worker, { type: 'module' })
function onError(err: any) {
error.value = err.message
}
watch(data, () => {
if ('error' in data.value) {
return onError(data.value.error)
}
if (data.value.type === MessageType.Status) {
status.value = data.value.message
messages.value = [...messages.value, data.value]
}
else {
messages.value = [...messages.value, data.value]
}
})
onMounted(() => {
// https://react.dev/learn/synchronizing-with-effects#fetching-data
let ignore = false // Flag to track if the effect is active
const audioStream = navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
autoGainControl: true,
noiseSuppression: true,
sampleRate: SAMPLE_RATE,
},
})
let worklet: AudioWorkletNode
let audioContext: AudioContext
let source: MediaStreamAudioSourceNode
audioStream
.then(async (stream) => {
if (ignore)
return // Exit if the effect has been cleaned up
audioContext = new (window.AudioContext || ('webkitAudioContext' in window && window.webkitAudioContext))({
sampleRate: SAMPLE_RATE,
latencyHint: 'interactive',
})
const analyser = audioContext.createAnalyser()
analyser.fftSize = 32
// NOTE: In Firefox, the following line may throw an error:
// "AudioContext.createMediaStreamSource: Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported."
// See the following bug reports for more information:
// - https://bugzilla.mozilla.org/show_bug.cgi?id=1674892
// - https://bugzilla.mozilla.org/show_bug.cgi?id=1674892
source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
const dataArray = new Uint8Array(analyser.frequencyBinCount)
const getAverageFrequency = () => {
analyser.getByteFrequencyData(dataArray)
return (
dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length
)
}
const updateFrequency = () => {
const freq = getAverageFrequency()
frequency.value = freq
requestAnimationFrame(updateFrequency)
}
updateFrequency()
await audioContext.audioWorklet.addModule(new URL(ProcessorWorklet, import.meta.url))
worklet = new AudioWorkletNode(audioContext, 'vad-processor', {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'discrete',
})
source.connect(worklet)
worklet.port.onmessage = (event) => {
const { buffer } = event.data
// Dispatch buffer for voice activity detection
post({ buffer })
}
})
.catch((err) => {
error.value = err.message
console.error(err)
})
return () => {
ignore = true // Mark the effect as cleaned up
audioStream.then(stream =>
stream.getTracks().forEach(track => track.stop()),
)
source?.disconnect()
worklet?.disconnect()
audioContext?.close()
}
})
function downloadTranscript() {
const content = messages.value
.filter(output => output.type === MessageType.Output)
.map(
output =>
`${formatDate(output.start)} - ${formatDate(output.end)} | ${output.message}`,
)
.join('\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transcript.txt'
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="h-full w-screen flex flex-col items-center justify-center bg-gray-900">
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{ opacity: 100 }"
:visible="{ opacity: 0 }"
class="fixed inset-0 z-20 h-full w-full flex flex-col items-center justify-center bg-black/90 p-2 text-center text-black backdrop-blur-md transition-all duration-2000 delay-1500 ease-in-out"
>
<h1 class="text-6xl text-white font-bold lg:text-8xl sm:text-7xl">
Moonshine Web
</h1>
<h2 class="text-2xl text-white">
Real-time in-browser speech recognition, powered by Transformers.js
</h2>
</div>
<template v-if="error">
<div class="h-full flex flex-col justify-center p-2 text-center">
<div class="mb-1 text-4xl text-white font-semibold md:text-5xl">
An error occurred
</div>
<div class="text-xl text-red-300">
{{ error }}
</div>
</div>
</template>
<template v-else>
<div class="absolute bottom-0 z-10 w-full overflow-hidden pb-8 text-center text-white">
<template v-for="(message, index) of messages" :key="index">
<div
:initial="{ opacity: 0, y: 25 }"
:enter="{ opacity: 1, y: 0 }"
:duration="200"
class="mb-1"
:class="[message.type === 'output' ? 'text-5xl' : 'text-2xl text-green-300 font-light']"
>
<Transition name="fade-up">
<div v-if="message.duration === 'until_next' && index === messages.length - 1">
{{ message.message }}
</div>
</Transition>
</div>
</template>
</div>
<TresCanvas window-size :alpha="true" :antialias="true" power-preference="high-performance" :output-color-space="SRGBColorSpace" :tone-mapping="ACESFilmicToneMapping">
<TresPerspectiveCamera :position="[0, 0, 8]" :fov="75" :near="0.1" :far="1000" />
<TresAmbientLight :intensity="0.5" />
<AnimatedMesh
:ready="status !== null"
:active="status === 'recording_start'"
:frequency="frequency"
/>
<BloomScene :frequency="frequency" />
</TresCanvas>
<div class="absolute bottom-6 right-6 z-10 flex flex-col space-y-2">
<button
class="h-10 w-10 flex items-center justify-center rounded-full bg-white shadow-md hover:bg-gray-100"
title="Download Transcript"
@click="() => downloadTranscript()"
>
<svg
class="h-7 w-7 cursor-pointer text-gray-800"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M13 11.15V4a1 1 0 1 0-2 0v7.15L8.78 8.374a1 1 0 1 0-1.56 1.25l4 5a1 1 0 0 0 1.56 0l4-5a1 1 0 1 0-1.56-1.25L13 11.15Z"
clipRule="evenodd"
/>
<path
fillRule="evenodd"
d="M9.657 15.874 7.358 13H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-2.358l-2.3 2.874a3 3 0 0 1-4.685 0ZM17 16a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
clipRule="evenodd"
/>
</svg>
</button>
<a
href="https://github.com/huggingface/transformers.js-examples/tree/main/moonshine-web" target="_blank"
class="h-10 w-10 flex cursor-pointer items-center justify-center rounded-full bg-white shadow-md hover:bg-gray-100"
title="Source Code"
>
<svg
class="h-7 w-7 text-gray-800"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
</template>
</div>
</template>
<style scoped>
.fade-up-enter-active,
.fade-up-leave-active {
transition: all 0.5s ease-in-out;
}
.fade-up-enter-from,
.fade-up-leave-to {
opacity: 0;
transform: translateY(-25px);
}
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { Mesh } from 'three'
import { useRenderLoop } from '@tresjs/core'
import { IcosahedronGeometry, ShaderMaterial } from 'three'
import { computed, ref } from 'vue'
const props = defineProps<{
ready: boolean
active: boolean
frequency: number
}>()
const MIN_WAVE_SIZE = 10
const AUDIO_SCALE = 0.5
const MAX_WAVE_SIZE = 60
const { onLoop } = useRenderLoop()
const colors = computed(() => ({
red: props.ready ? (props.active ? 1 : 0) : 0.1,
green: props.ready ? (props.active ? 0 : 1) : 0.1,
blue: props.ready ? (props.active ? 1 : 0) : 0.1,
}))
const mesh = ref<Mesh>()
const geometry = computed(() => {
return new IcosahedronGeometry(3, 20)
})
const material = computed(() => {
return new ShaderMaterial({
uniforms: {
u_time: { value: 0.0 },
u_frequency: { value: 0.0 },
u_red: { value: 0.0 },
u_green: { value: 0.0 },
u_blue: { value: 0.0 },
},
vertexShader: document.getElementById('vertexshader')!.textContent || '',
fragmentShader: document.getElementById('fragmentshader')!.textContent || '',
wireframe: true,
})
})
const uniforms = material.value.uniforms
onLoop(({ clock }) => {
const time = clock.getElapsedTime()
uniforms.u_time.value = time
uniforms.u_frequency.value = Math.min(
MIN_WAVE_SIZE + AUDIO_SCALE * props.frequency,
MAX_WAVE_SIZE,
)
uniforms.u_red.value = colors.value.red
uniforms.u_green.value = colors.value.green
uniforms.u_blue.value = colors.value.blue
})
</script>
<template>
<TresMesh ref="mesh" :geometry="geometry" :material="material" />
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { extend, useLoop, useTres } from '@tresjs/core'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { shallowRef, watch } from 'vue'
const props = defineProps<{
frequency: number
}>()
extend({ EffectComposer, OutputPass, UnrealBloomPass, RenderPass })
const { renderer, scene, camera, sizes } = useTres()
const composer = shallowRef<EffectComposer>()
useLoop().render(() => {
if (composer.value) {
composer.value!.render()
}
})
watch([sizes.width, sizes.height], () => {
composer.value?.setSize(sizes.width.value, sizes.height.value)
})
</script>
<template>
<TresEffectComposer
ref="composer"
:args="[renderer]"
:set-size="[sizes.width.value, sizes.height.value]"
>
<TresRenderPass
:args="[scene, camera]"
attach="passes-0"
/>
<TresUnrealBloomPass
:args="[[sizes.width, sizes.height], 0.2, 1, 0]"
:strength="0.2 + props.frequency / 1000"
attach="passes-1"
/>
<TresOutputPass
attach="passes-2"
:set-size="[sizes.width.value, sizes.height.value]"
/>
</TresEffectComposer>
</template>
@@ -0,0 +1,53 @@
/**
* Sample rate of the audio.
* Coindicentally, this is the same for both models (Moonshine and Silero VAD)
*/
export const SAMPLE_RATE = 16000
export const SAMPLE_RATE_MS = SAMPLE_RATE / 1000
/**
* Probabilities ABOVE this value are considered as SPEECH
*/
export const SPEECH_THRESHOLD = 0.3
/**
* If current state is SPEECH, and the probability of the next state
* is below this value, it is considered as NON-SPEECH.
*/
export const EXIT_THRESHOLD = 0.1
/**
* After each speech chunk, wait for at least this amount of silence
* before considering the next chunk as a new speech chunk
*/
export const MIN_SILENCE_DURATION_MS = 400
export const MIN_SILENCE_DURATION_SAMPLES
= MIN_SILENCE_DURATION_MS * SAMPLE_RATE_MS
/**
* Pad the speech chunk with this amount each side
*/
export const SPEECH_PAD_MS = 80
export const SPEECH_PAD_SAMPLES = SPEECH_PAD_MS * SAMPLE_RATE_MS
/**
* Final speech chunks below this duration are discarded
*/
export const MIN_SPEECH_DURATION_SAMPLES = 250 * SAMPLE_RATE_MS // 250 ms
/**
* Maximum duration of audio that can be handled by Moonshine
*/
export const MAX_BUFFER_DURATION = 30
/**
* Size of the incoming buffers
*/
export const NEW_BUFFER_SIZE = 512
/**
* The number of previous buffers to keep, to ensure the audio is padded correctly
*/
export const MAX_NUM_PREV_BUFFERS = Math.ceil(
SPEECH_PAD_SAMPLES / NEW_BUFFER_SIZE,
)
@@ -0,0 +1,40 @@
const MIN_CHUNK_SIZE = 512
let globalPointer = 0
const globalBuffer = new Float32Array(MIN_CHUNK_SIZE)
class VADProcessor extends AudioWorkletProcessor {
process(inputs: Float32Array[][], _outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
const buffer = inputs[0][0]
if (!buffer)
return false // 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 // Keep the processor alive
}
}
registerProcessor('vad-processor', VADProcessor)
+42
View File
@@ -0,0 +1,42 @@
export enum MessageType {
Status = 'status',
Output = 'output',
Info = 'info',
}
export enum MessageStatus {
RecordingStart = 'recording_start',
RecordingEnd = 'recording_end',
}
export enum Duration {
UntilNext = 'until_next',
}
export interface MessageEventStatus {
type: MessageType.Status
status: MessageStatus
message: string
duration: Duration
}
export interface MessageEventOutput {
type: MessageType.Output
buffer: Float32Array<any>
message: string
start: number
end: number
duration: number
}
export interface MessageEventInfo {
type: MessageType.Info
message: string
duration?: Duration.UntilNext
}
export interface MessageEventError {
error: unknown
}
export type MessageEvent = MessageEventError | MessageEventStatus | MessageEventOutput | MessageEventInfo
+238
View File
@@ -0,0 +1,238 @@
/* eslint-disable antfu/no-top-level-await */
/* eslint-disable no-restricted-globals */
import type { MessageEventError, MessageEventInfo, MessageEventOutput, MessageEventStatus } from './types'
import { AutoModel, pipeline, Tensor } from '@huggingface/transformers'
import {
EXIT_THRESHOLD,
MAX_BUFFER_DURATION,
MAX_NUM_PREV_BUFFERS,
MIN_SILENCE_DURATION_SAMPLES,
MIN_SPEECH_DURATION_SAMPLES,
SAMPLE_RATE,
SPEECH_PAD_SAMPLES,
SPEECH_THRESHOLD,
} from '../constants'
import { supportsWebGPU } from '../utils'
import { Duration, MessageStatus, MessageType } from './types'
const device = (await supportsWebGPU()) ? 'webgpu' : 'wasm'
self.postMessage({ type: MessageType.Info, message: `Using device: "${device}"` } satisfies MessageEventInfo)
self.postMessage({
type: MessageType.Info,
message: 'Loading models...',
duration: Duration.UntilNext,
} satisfies MessageEventInfo)
// Load models
const silero_vad = await AutoModel.from_pretrained(
'onnx-community/silero-vad',
{
config: { model_type: 'custom' },
dtype: 'fp32', // Full-precision
},
).catch((error) => {
self.postMessage({ error } satisfies MessageEventError)
throw error
})
const DEVICE_DTYPE_CONFIGS = {
webgpu: {
encoder_model: 'fp32',
decoder_model_merged: 'q4',
},
wasm: {
encoder_model: 'fp32',
decoder_model_merged: 'q8',
},
}
const transcriber = await pipeline(
'automatic-speech-recognition',
'onnx-community/moonshine-base-ONNX', // or "onnx-community/whisper-tiny.en",
{
device,
dtype: DEVICE_DTYPE_CONFIGS[device],
},
).catch((error) => {
self.postMessage({ error } satisfies MessageEventError)
throw error
})
await transcriber(new Float32Array(SAMPLE_RATE)) // Compile shaders
self.postMessage({ type: 'status', status: 'ready', message: 'Ready!' })
// Transformers.js currently doesn't support simultaneous inference,
// so we need to chain the inference promises.
let inferenceChain = Promise.resolve()
// Global audio buffer to store incoming audio
const BUFFER = new Float32Array(MAX_BUFFER_DURATION * SAMPLE_RATE)
let bufferPointer = 0
// Initial state for VAD
const sr = new Tensor('int64', [SAMPLE_RATE], [])
let state = new Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128])
// Whether we are in the process of adding audio to the buffer
let isRecording = false
/**
* Perform Voice Activity Detection (VAD)
* @param {Float32Array} buffer The new audio buffer
* @returns {Promise<boolean>} `true` if the buffer is speech, `false` otherwise.
*/
async function vad(buffer: Float32Array<ArrayBuffer>) {
const input = new Tensor('float32', buffer, [1, buffer.length])
const { stateN, output } = await (inferenceChain = inferenceChain.then(_ =>
silero_vad({ input, sr, state }),
))
state = stateN // Update state
const isSpeech = output.data[0]
// Use heuristics to determine if the buffer is speech or not
return (
// Case 1: We are above the threshold (definitely speech)
isSpeech > SPEECH_THRESHOLD
// Case 2: We are in the process of recording, and the probability is above the negative (exit) threshold
|| (isRecording && isSpeech >= EXIT_THRESHOLD)
)
}
/**
* Transcribe the audio buffer
* @param {Float32Array} buffer The audio buffer
* @param {object} data Additional data
* @param {number} data.start The start time of the speech segment
* @param {number} data.end The end time of the speech segment
* @param {number} data.duration The duration of the speech segment
*/
async function transcribe(buffer: Float32Array<any>, data: { start: number, end: number, duration: number }) {
const { text } = await (inferenceChain = inferenceChain.then(_ =>
transcriber(buffer),
))
self.postMessage({ type: MessageType.Output, buffer, message: text, ...data } satisfies MessageEventOutput)
}
// Track the number of samples after the last speech chunk
let postSpeechSamples = 0
function reset(offset = 0) {
self.postMessage({
type: MessageType.Status,
status: MessageStatus.RecordingEnd,
message: 'Transcribing...',
duration: Duration.UntilNext,
} satisfies MessageEventStatus)
BUFFER.fill(0, offset)
bufferPointer = offset
isRecording = false
postSpeechSamples = 0
}
const prevBuffers: Array<Float32Array<ArrayBuffer>> = []
function dispatchForTranscriptionAndResetAudioBuffer(overflow?: Float32Array<ArrayBuffer>) {
// Get start and end time of the speech segment, minus the padding
const now = Date.now()
const end
= now - ((postSpeechSamples + SPEECH_PAD_SAMPLES) / SAMPLE_RATE) * 1000
const start = end - (bufferPointer / SAMPLE_RATE) * 1000
const duration = end - start
const overflowLength = overflow?.length ?? 0
// Send the audio buffer to the worker
const buffer = BUFFER.slice(0, bufferPointer + SPEECH_PAD_SAMPLES)
const prevLength = prevBuffers.reduce((acc, b) => acc + b.length, 0)
const paddedBuffer = new Float32Array<any>(prevLength + buffer.length)
let offset = 0
for (const prev of prevBuffers) {
paddedBuffer.set(prev, offset)
offset += prev.length
}
paddedBuffer.set(buffer, offset)
transcribe(paddedBuffer, { start, end, duration })
// Set overflow (if present) and reset the rest of the audio buffer
if (overflow) {
BUFFER.set(overflow, 0)
}
reset(overflowLength)
}
self.onmessage = async (event) => {
const { buffer } = event.data as { buffer: Float32Array<ArrayBuffer> }
const wasRecording = isRecording // Save current state
const isSpeech = await vad(buffer)
if (!wasRecording && !isSpeech) {
// We are not recording, and the buffer is not speech,
// so we will probably discard the buffer. So, we insert
// into a FIFO queue with maximum size of PREV_BUFFER_SIZE
if (prevBuffers.length >= MAX_NUM_PREV_BUFFERS) {
// If the queue is full, we discard the oldest buffer
prevBuffers.shift()
}
prevBuffers.push(buffer)
return
}
const remaining = BUFFER.length - bufferPointer
if (buffer.length >= remaining) {
// The buffer is larger than (or equal to) the remaining space in the global buffer,
// so we perform transcription and copy the overflow to the global buffer
BUFFER.set(buffer.subarray(0, remaining), bufferPointer)
bufferPointer += remaining
// Dispatch the audio buffer
const overflow = buffer.subarray(remaining)
dispatchForTranscriptionAndResetAudioBuffer(overflow)
return
}
else {
// The buffer is smaller than the remaining space in the global buffer,
// so we copy it to the global buffer
BUFFER.set(buffer, bufferPointer)
bufferPointer += buffer.length
}
if (isSpeech) {
if (!isRecording) {
// Indicate start of recording
self.postMessage({
type: MessageType.Status,
status: MessageStatus.RecordingStart,
message: 'Listening...',
duration: Duration.UntilNext,
} satisfies MessageEventStatus)
}
// Start or continue recording
isRecording = true
postSpeechSamples = 0 // Reset the post-speech samples
return
}
postSpeechSamples += buffer.length
// At this point we're confident that we were recording (wasRecording === true), but the latest buffer is not speech.
// So, we check whether we have reached the end of the current audio chunk.
if (postSpeechSamples < MIN_SILENCE_DURATION_SAMPLES) {
// There was a short pause, but not long enough to consider the end of a speech chunk
// (e.g., the speaker took a breath), so we continue recording
return
}
if (bufferPointer < MIN_SPEECH_DURATION_SAMPLES) {
// The entire buffer (including the new chunk) is smaller than the minimum
// duration of a speech chunk, so we can safely discard the buffer.
reset()
return
}
dispatchForTranscriptionAndResetAudioBuffer()
}
+14
View File
@@ -0,0 +1,14 @@
import Tres from '@tresjs/core'
import { MotionPlugin } from '@vueuse/motion'
import { createApp } from 'vue'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import './styles/main.css'
import 'uno.css'
createApp(App)
.use(MotionPlugin)
.use(Tres)
.mount('#app')
@@ -0,0 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
* {
font-family: 'Poppins', sans-serif;
}
html {
overflow: hidden;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
+26
View File
@@ -0,0 +1,26 @@
export function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleString('zh', {
hour12: false,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
fractionalSecondDigits: 3,
})
}
export async function supportsWebGPU() {
try {
if (!('gpu' in navigator) || !navigator.gpu)
return false
await navigator.gpu.requestAdapter()
return true
}
catch (e) {
console.error(e)
return false
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"DOM",
"ESNext",
"WebWorker"
],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"~/*": ["src/*"]
},
"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",
// @webgpu/types
// https://www.npmjs.com/package/@webgpu/types
"@webgpu/types"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
},
"exclude": ["dist", "node_modules", "cypress"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, mergeConfigs, presetWebFonts } from 'unocss'
import UnoCSSConfig from '../../uno.config'
export default defineConfig(mergeConfigs([
UnoCSSConfig,
{
presets: [
presetWebFonts({
fonts: {
sans: 'DM Sans',
serif: 'DM Serif Display',
mono: 'DM Mono',
},
}),
],
},
]))
+17
View File
@@ -0,0 +1,17 @@
import { templateCompilerOptions } from '@tresjs/core'
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
Vue({
// Other config
...templateCompilerOptions,
}),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
],
worker: { format: 'es' },
})
+1 -5
View File
@@ -4,11 +4,7 @@
<meta charset="UTF-8" />
<title>Whisper Realtime (WebGPU)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Whisper Realtime" />
<link rel="icon" type="image/png" href="/logo.png" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
-6
View File
@@ -16,9 +16,3 @@ from = "/*"
to = "/index.html"
status = 200
force = false
[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json"
+1
View File
@@ -26,6 +26,7 @@
"devDependencies": {
"@huggingface/transformers": "^3.2.1",
"@vitejs/plugin-vue": "^5.2.1",
"@webgpu/types": "^0.1.52",
"vue-tsc": "^2.1.10"
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import Progress from './components/Progress.vue'
import WhisperLanguageSelect from './components/WhisperLanguageSelect.vue'
import Worker from './libs/worker?worker&url'
const IS_WEBGPU_AVAILABLE = !!(navigator as unknown as any).gpu
const IS_WEBGPU_AVAILABLE = (('gpu' in navigator) && !navigator.gpu)
const WHISPER_SAMPLING_RATE = 16_000
const MAX_AUDIO_LENGTH = 30 // seconds
+9 -8
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-restricted-globals */
import type {
ModelOutput,
PreTrainedModel,
@@ -57,7 +58,7 @@ async function generate({ audio, language }: { audio: ArrayBuffer, language: str
processing = true
// Tell the main thread we are starting
globalThis.postMessage({ status: 'start' })
self.postMessage({ status: 'start' })
// Retrieve the text-generation pipeline.
const [tokenizer, processor, model] = await AutomaticSpeechRecognitionPipeline.getInstance()
@@ -72,7 +73,7 @@ async function generate({ audio, language }: { audio: ArrayBuffer, language: str
tps = numTokens / (performance.now() - startTime) * 1000
}
globalThis.postMessage({
self.postMessage({
status: 'update',
output,
tps,
@@ -100,7 +101,7 @@ async function generate({ audio, language }: { audio: ArrayBuffer, language: str
const outputText = tokenizer.batch_decode(outputs as Tensor, { skip_special_tokens: true })
// Send the output back to the main thread
globalThis.postMessage({
self.postMessage({
status: 'complete',
output: outputText,
})
@@ -108,7 +109,7 @@ async function generate({ audio, language }: { audio: ArrayBuffer, language: str
}
async function load() {
globalThis.postMessage({
self.postMessage({
status: 'loading',
data: 'Loading model...',
})
@@ -118,10 +119,10 @@ async function load() {
const [tokenizer, processor, model] = await AutomaticSpeechRecognitionPipeline.getInstance((x) => {
// We also add a progress callback to the pipeline so that we can
// track model loading.
globalThis.postMessage(x)
self.postMessage(x)
})
globalThis.postMessage({
self.postMessage({
status: 'loading',
data: 'Compiling shaders and warming up model...',
})
@@ -132,10 +133,10 @@ async function load() {
max_new_tokens: 1,
})
globalThis.postMessage({ status: 'ready' })
self.postMessage({ status: 'ready' })
}
// Listen for messages from the main thread
globalThis.addEventListener('message', async (e) => {
self.addEventListener('message', async (e) => {
const { type, data } = e.data
switch (type) {
+7 -2
View File
@@ -2,7 +2,11 @@
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"lib": [
"DOM",
"ESNext",
"WebWorker"
],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
@@ -12,7 +16,8 @@
"resolveJsonModule": true,
"types": [
"vitest",
"vite/client"
"vite/client",
"@webgpu/types"
],
"allowJs": true,
"strict": true,
+141 -4
View File
@@ -102,6 +102,49 @@ importers:
specifier: ^3.99.0
version: 3.99.0(@cloudflare/workers-types@4.20241218.0)
packages/moonshine-web:
dependencies:
'@tresjs/core':
specifier: ^4.3.1
version: 4.3.1(three@0.171.0)(vue@3.5.13(typescript@5.7.2))
'@unocss/reset':
specifier: ^0.65.2
version: 0.65.2
'@vueuse/core':
specifier: ^12.1.0
version: 12.1.0(typescript@5.7.2)
'@vueuse/motion':
specifier: ^2.2.6
version: 2.2.6(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2))
ofetch:
specifier: ^1.4.1
version: 1.4.1
three:
specifier: ^0.171.0
version: 0.171.0
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.7.2)
devDependencies:
'@huggingface/transformers':
specifier: ^3.2.1
version: 3.2.1
'@types/audioworklet':
specifier: ^0.0.65
version: 0.0.65
'@types/three':
specifier: ^0.171.0
version: 0.171.0
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.13(typescript@5.7.2))
'@webgpu/types':
specifier: ^0.1.52
version: 0.1.52
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(typescript@5.7.2)
packages/stage:
dependencies:
'@11labs/client':
@@ -370,6 +413,9 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.13(typescript@5.7.2))
'@webgpu/types':
specifier: ^0.1.52
version: 0.1.52
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(typescript@5.7.2)
@@ -2487,6 +2533,9 @@ packages:
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/audioworklet@0.0.65':
resolution: {integrity: sha512-JnPJtR94nLPBBQ2iUuZ3Te6FzeABVXXuATR3zw3Hl/EvSYvU8Ipd2tI8/svvTmZ5sPUEnT/fDUF7f2kSjXWC3w==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -3124,8 +3173,8 @@ packages:
'@vueuse/shared@12.1.0':
resolution: {integrity: sha512-tnuYb6Rp9dwMjsi/gukNeE+En3RFOfzrnVZX1vwbvq7+MbCkBNnXFvKgMGPlo1TQxgVd46D3NYwhBmM8Ioxd2A==}
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
'@webgpu/types@0.1.52':
resolution: {integrity: sha512-eI883Nlag2hGIkhXxAnq8s4APpqXWuPL3Gbn2ghiU12UjLvfCbVqHK4XfXl3eLRTatqcMmeK7jws7IwWsGfbzw==}
'@xsai/generate-text@0.0.22':
resolution: {integrity: sha512-3ZkxR3QfN0lkcCD4NM6glkA9+cWxPodHx73qnlSKzFV/m1McAI42JPKWpFOxfQdxkAQAkZ5cD5Et7PZs8Rc7bQ==}
@@ -8949,6 +8998,34 @@ snapshots:
- supports-color
optional: true
'@nuxt/kit@3.14.1592(rollup@4.29.1)':
dependencies:
'@nuxt/schema': 3.14.1592(rollup@4.29.1)
c12: 2.0.1
consola: 3.2.3
defu: 6.1.4
destr: 2.0.3
globby: 14.0.2
hash-sum: 2.0.0
ignore: 6.0.2
jiti: 2.4.0
klona: 2.0.6
knitwork: 1.2.0
mlly: 1.7.3
pathe: 1.1.2
pkg-types: 1.2.1
scule: 1.3.0
semver: 7.6.3
ufo: 1.5.4
unctx: 2.4.0
unimport: 3.14.2(rollup@4.29.1)
untyped: 1.5.1
transitivePeerDependencies:
- magicast
- rollup
- supports-color
optional: true
'@nuxt/schema@3.14.1592(rollup@2.79.1)':
dependencies:
c12: 2.0.1
@@ -8970,6 +9047,27 @@ snapshots:
- supports-color
optional: true
'@nuxt/schema@3.14.1592(rollup@4.29.1)':
dependencies:
c12: 2.0.1
compatx: 0.1.8
consola: 3.2.3
defu: 6.1.4
hookable: 5.5.3
pathe: 1.1.2
pkg-types: 1.2.1
scule: 1.3.0
std-env: 3.8.0
ufo: 1.5.4
uncrypto: 0.1.3
unimport: 3.14.2(rollup@4.29.1)
untyped: 1.5.1
transitivePeerDependencies:
- magicast
- rollup
- supports-color
optional: true
'@oxc-resolver/binding-darwin-arm64@2.1.1':
optional: true
@@ -9439,6 +9537,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/audioworklet@0.0.65': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.34
@@ -9507,7 +9607,7 @@ snapshots:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
'@types/webxr': 0.5.20
'@webgpu/types': 0.1.51
'@webgpu/types': 0.1.52
fflate: 0.8.2
meshoptimizer: 0.18.1
@@ -10410,6 +10510,23 @@ snapshots:
- rollup
- supports-color
'@vueuse/motion@2.2.6(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.13(typescript@5.7.2))
'@vueuse/shared': 10.11.1(vue@3.5.13(typescript@5.7.2))
csstype: 3.1.3
framesync: 6.1.2
popmotion: 11.0.5
style-value-types: 5.1.2
vue: 3.5.13(typescript@5.7.2)
optionalDependencies:
'@nuxt/kit': 3.14.1592(rollup@4.29.1)
transitivePeerDependencies:
- '@vue/composition-api'
- magicast
- rollup
- supports-color
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.2))
@@ -10430,7 +10547,7 @@ snapshots:
transitivePeerDependencies:
- typescript
'@webgpu/types@0.1.51': {}
'@webgpu/types@0.1.52': {}
'@xsai/generate-text@0.0.22':
dependencies:
@@ -14741,6 +14858,26 @@ snapshots:
- rollup
optional: true
unimport@3.14.2(rollup@4.29.1):
dependencies:
'@rollup/pluginutils': 5.1.3(rollup@4.29.1)
acorn: 8.14.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 0.5.1
magic-string: 0.30.14
mlly: 1.7.3
pathe: 1.1.2
picomatch: 4.0.2
pkg-types: 1.2.1
scule: 1.3.0
strip-literal: 2.1.1
tinyglobby: 0.2.10
unplugin: 1.16.0
transitivePeerDependencies:
- rollup
optional: true
unimport@3.14.5(rollup@2.79.1):
dependencies:
'@rollup/pluginutils': 5.1.3(rollup@2.79.1)