refactor(apps|packages): migrate out moonshine-web, whisper-webgpu, lobe-icons to @proj-airi

This commit is contained in:
Neko Ayaka
2025-03-08 03:00:43 +08:00
parent f951815640
commit 971045a670
48 changed files with 80 additions and 2964 deletions
@@ -15,18 +15,6 @@ jobs:
fetch-depth: 0
lfs: true
- run: |-
git clone https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/moeru-ai/moonshine-web-vue --depth 1 apps/moonshine-web/dist
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_USERNAME: ${{ secrets.HF_USERNAME }}
- run: |-
git clone https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/moeru-ai/whisper-webgpu-vue --depth 1 apps/whisper-webgpu/dist
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_USERNAME: ${{ secrets.HF_USERNAME }}
- run: |-
git clone https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/moeru-ai/airi --depth 1 apps/stage-web/dist
env:
@@ -45,48 +33,6 @@ jobs:
TARGET_HUGGINGFACE_SPACE: 'true'
VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true'
- id: moonshine_web_diff
working-directory: ./apps/moonshine-web/dist
run: |-
git lfs ls-files --all
git add .
if [[ -n $(git status --porcelain) ]]; then
echo "changes=true" >> "$GITHUB_OUTPUT";
fi
- if: steps.moonshine_web_diff.outputs.changes == 'true'
working-directory: ./apps/moonshine-web/dist
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_USERNAME: ${{ secrets.HF_USERNAME }}
run: |-
git config --local user.email "neko@ayaka.moe"
git config --local user.name "Neko Ayaka"
git commit -m "release: build ${{ github.sha }}"
git lfs push origin main --all
git push -f
- id: whisper_webgpu_diff
working-directory: ./apps/whisper-webgpu/dist
run: |-
git lfs ls-files --all
git add .
if [[ -n $(git status --porcelain) ]]; then
echo "changes=true" >> "$GITHUB_OUTPUT";
fi
- if: steps.whisper_webgpu_diff.outputs.changes == 'true'
working-directory: ./apps/whisper-webgpu/dist
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_USERNAME: ${{ secrets.HF_USERNAME }}
run: |-
git config --local user.email "neko@ayaka.moe"
git config --local user.name "Neko Ayaka"
git commit -m "release: build ${{ github.sha }}"
git lfs push origin main --all
git push -f
- id: airi_diff
working-directory: ./apps/stage-web/dist
run: |-
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-18
View File
@@ -1,18 +0,0 @@
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;"]
-57
View File
@@ -1,57 +0,0 @@
<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
@@ -1,136 +0,0 @@
<!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>
-19
View File
@@ -1,19 +0,0 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/moonshine-web... run build"
publish = "/apps/moonshine-web/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
-37
View File
@@ -1,37 +0,0 @@
{
"name": "@proj-airi/moonshine-web",
"type": "module",
"private": true,
"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.3",
"@unocss/reset": "^66.1.0-beta.3",
"@vueuse/core": "^12.8.2",
"@vueuse/motion": "^2.2.6",
"ofetch": "^1.4.1",
"three": "^0.174.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@huggingface/transformers": "^3.4.0",
"@types/audioworklet": "^0.0.71",
"@types/three": "^0.174.0",
"@vitejs/plugin-vue": "^5.2.1",
"@webgpu/types": "^0.1.55",
"hfup": "workspace:^",
"vue-tsc": "^2.2.8"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

-280
View File
@@ -1,280 +0,0 @@
<script setup lang="ts">
import type { MessageEvent, MessageEventBufferRequest, MessageEventInfo, MessageEventLoad, 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 | MessageEventBufferRequest | MessageEventLoad>>([])
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.push(data.value)
// pop out the other messages except the last status message
if (messages.value.length > 1) {
messages.value = messages.value.slice(-1)
}
}
else {
messages.value.push(data.value)
// pop out the last message
if (messages.value.length > 1) {
messages.value = messages.value.slice(-1)
}
}
})
onMounted(() => {
post({ type: MessageType.Load } satisfies MessageEventLoad)
})
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({ type: MessageType.Request, buffer } satisfies MessageEventBufferRequest)
}
})
.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">
<TransitionGroup name="fade-up" tag="div">
<div
v-for="(message) of messages" :key="message.message || ''"
: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']"
>
<div>
{{ message.message }}
</div>
</div>
</TransitionGroup>
</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 {
opacity: 0;
transform: translateY(25px);
}
.fade-up-leave-to {
opacity: 0;
transform: translateY(-25px);
}
</style>
@@ -1,62 +0,0 @@
<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>
@@ -1,49 +0,0 @@
<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>
-53
View File
@@ -1,53 +0,0 @@
/**
* 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,
)
-40
View File
@@ -1,40 +0,0 @@
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)
-59
View File
@@ -1,59 +0,0 @@
export enum MessageType {
Status = 'status',
Output = 'output',
Info = 'info',
Request = 'request',
Error = 'error',
Load = 'load',
}
export enum MessageStatus {
RecordingStart = 'recording_start',
RecordingEnd = 'recording_end',
Ready = 'ready',
}
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 MessageEventBufferRequest {
type: MessageType.Request
buffer: Float32Array<any>
message?: string
}
export interface MessageEventError {
type: MessageType.Error
error: unknown
message?: string
}
export interface MessageEventLoad {
type: MessageType.Load
message?: string
}
export type MessageEvent = MessageEventError | MessageEventStatus | MessageEventOutput | MessageEventInfo | MessageEventBufferRequest | MessageEventLoad
-275
View File
@@ -1,275 +0,0 @@
/* eslint-disable no-restricted-globals */
import type { AutomaticSpeechRecognitionPipeline, PreTrainedModel } from '@huggingface/transformers'
import type { MessageEvent as InternalMessageEvent, MessageEventBufferRequest, 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'
export type DType = Record<string, Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['dtype']>, string>[string]>
export type Device = Extract<Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['device']>, Record<string, any>>, 'webgpu' | 'wasm'>
export type PretrainedConfig = NonNullable<Parameters<typeof AutoModel.from_pretrained>[1]>['config']
// Load models
let silero_vad: PreTrainedModel
let transcriber: AutomaticSpeechRecognitionPipeline
// 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
// Track the number of samples after the last speech chunk
let postSpeechSamples = 0
const DEVICE_DTYPE_CONFIGS: Record<Device, DType> = {
webgpu: {
encoder_model: 'fp32',
decoder_model_merged: 'q4',
},
wasm: {
encoder_model: 'fp32',
decoder_model_merged: 'q8',
},
}
async function newVADModel() {
// Load models
return await AutoModel.from_pretrained(
'onnx-community/silero-vad',
{
config: { model_type: 'custom' } as PretrainedConfig,
dtype: 'fp32', // Full-precision
},
).catch((error) => {
self.postMessage({ type: MessageType.Error, error } satisfies MessageEventError)
throw error
})
}
async function newAutomaticSpeechRecognitionPipeline(device: Device) {
return 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({ type: MessageType.Error, error } satisfies MessageEventError)
throw error
})
}
/**
* 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>) {
if (silero_vad === undefined) {
console.warn('VAD model not loaded yet')
return false
}
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 }) {
if (transcriber === undefined) {
console.warn('Transcriber model not loaded yet')
return
}
// @ts-expect-error - chain
const { text }: { text: string } = await (inferenceChain = inferenceChain.then(_ => transcriber(buffer)))
self.postMessage({ type: MessageType.Output, buffer, message: text, ...data } satisfies MessageEventOutput)
}
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)
}
async function load() {
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
silero_vad = await newVADModel()
transcriber = await newAutomaticSpeechRecognitionPipeline(device)
await transcriber(new Float32Array(SAMPLE_RATE)) // Compile shaders
self.postMessage({ type: 'status', status: 'ready', message: 'Ready!' })
self.onmessage = async (event) => {
const { buffer } = event.data as MessageEventBufferRequest
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()
}
}
self.addEventListener('message', (event) => {
const { type } = event.data as InternalMessageEvent
switch (type) {
case MessageType.Load:
load()
break
}
})
-14
View File
@@ -1,14 +0,0 @@
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')
-18
View File
@@ -1,18 +0,0 @@
@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
@@ -1,26 +0,0 @@
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
}
}
-34
View File
@@ -1,34 +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",
// @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"]
}
-33
View File
@@ -1,33 +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',
},
}),
presetIcons({
scale: 1.2,
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
})
-33
View File
@@ -1,33 +0,0 @@
import { templateCompilerOptions } from '@tresjs/core'
import Vue from '@vitejs/plugin-vue'
import { LFS, SpaceCard } from 'hfup/vite'
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(),
// HuggingFace Spaces
LFS(),
SpaceCard({
title: 'Moonshine Web (Vue)',
emoji: '🌙',
colorFrom: 'yellow',
colorTo: 'yellow',
sdk: 'static',
pinned: false,
license: 'mit',
models: ['onnx-community/moonshine-base-ONNX'],
short_description: 'Yet another Real-time in-browser STT, re-implemented in Vue',
thumbnail: 'https://raw.githubusercontent.com/moeru-ai/airi/refs/heads/main/packages/moonshine-web/public/banner.png',
}),
],
worker: { format: 'es' },
})
+1 -1
View File
@@ -97,7 +97,7 @@
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@proj-airi/drizzle-duckdb-wasm": "workspace:^",
"@proj-airi/elevenlabs": "workspace:^",
"@proj-airi/lobe-icons": "workspace:^",
"@proj-airi/lobe-icons": "^0.3.6",
"@proj-airi/provider-transformers": "workspace:^",
"@proj-airi/ui-transitions": "workspace:^",
"@proj-airi/unplugin-download": "workspace:^",
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-18
View File
@@ -1,18 +0,0 @@
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;"]
-13
View File
@@ -1,13 +0,0 @@
<h1 align="center">Whisper Realtime Demo (WebGPU)</h1>
<p align="center">
[<a href="https://airi-whisper-webgpu.netlify.app/">Try it</a>]
</p>
> Heavily inspired by [Real-time Whisper WebGPU](https://huggingface.co/spaces/Xenova/realtime-whisper-webgpu)
## Acknowledgements
Great thanks to what Xenova have done.
> [Source code](https://github.com/huggingface/transformers.js/tree/7a58d6e11968dd85dc87ce37b2ab37213165889a/examples/webgpu-whisper)
-22
View File
@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<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="/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">
<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>
-19
View File
@@ -1,19 +0,0 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/whisper-webgpu... run build"
publish = "/apps/whisper-webgpu/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
-32
View File
@@ -1,32 +0,0 @@
{
"name": "@proj-airi/whisper-webgpu",
"type": "module",
"private": true,
"description": "Yet another WebGPU based Whisper Realtime STT re-implemented",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"scripts": {
"build": "vite build",
"dev": "vite --port 5174",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@unocss/reset": "^66.1.0-beta.3",
"@vueuse/core": "^12.8.2",
"ofetch": "^1.4.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@huggingface/transformers": "^3.4.0",
"@vitejs/plugin-vue": "^5.2.1",
"@webgpu/types": "^0.1.55",
"hfup": "workspace:^",
"vue-tsc": "^2.2.8"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

-257
View File
@@ -1,257 +0,0 @@
<script setup lang="ts">
import type { MessageEvents, ProgressMessageEvents } from './libs/types'
import { useDevicesList, useScreenSafeArea, useUserMedia, useWebWorker } from '@vueuse/core'
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
import AudioVisualizer from './components/AudioVisualizer.vue'
import Progress from './components/Progress.vue'
import WhisperLanguageSelect from './components/WhisperLanguageSelect.vue'
import Worker from './libs/worker?worker&url'
const IS_WEBGPU_AVAILABLE = (('gpu' in navigator) && navigator.gpu)
const WHISPER_SAMPLING_RATE = 16_000
const MAX_AUDIO_LENGTH = 30 // seconds
const MAX_SAMPLES = WHISPER_SAMPLING_RATE * MAX_AUDIO_LENGTH
const { top, right, bottom, left } = useScreenSafeArea()
const status = ref<'loading' | 'ready' | null>(null)
const loadingMessage = ref('')
const progressItems = ref<ProgressMessageEvents[]>([])
const text = ref('')
const tps = ref<number>()
const language = ref('en')
const recorder = ref<MediaRecorder>()
const recording = ref(false)
const isProcessing = ref(false)
const chunks = ref<Blob[]>([])
const audioContextRef = ref<AudioContext | null>(null)
const { post, data } = useWebWorker<MessageEvents>(Worker, { type: 'module' })
const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
const selectedAudioInput = ref<ConstrainDOMString>()
const constraints = computed(() => ({ audio: { deviceId: selectedAudioInput.value } }))
const { stream } = useUserMedia({ constraints, enabled: true, autoSwitch: true })
watch(data, (e) => {
switch (e.status) {
case 'loading':
status.value = 'loading'
loadingMessage.value = e.data
break
case 'initiate':
progressItems.value.push(e)
break
case 'progress':
progressItems.value = progressItems.value.map((item) => {
if (item.file === e.file) {
return { ...item, ...e }
}
return item
})
break
case 'done':
progressItems.value = progressItems.value.filter(item => item.file !== e.file)
break
case 'ready':
status.value = 'ready'
recorder.value?.start()
break
case 'start':
isProcessing.value = true
recorder.value?.requestData()
break
case 'update':
tps.value = e.tps
break
case 'complete':
isProcessing.value = false
text.value = e.output[0] || ''
break
}
})
watch(stream, () => {
if (!stream.value)
return
recorder.value = new MediaRecorder(stream.value)
audioContextRef.value = new AudioContext({ sampleRate: WHISPER_SAMPLING_RATE })
recorder.value.onstart = () => {
recording.value = true
chunks.value = []
}
recorder.value.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.value.push(e.data)
}
else {
setTimeout(() => {
recorder.value?.requestData()
}, 25)
}
}
recorder.value.onstop = () => {
recording.value = false
}
})
watchEffect(() => {
if (!recorder.value)
return
if (!recording.value)
return
if (isProcessing.value)
return
if (status.value !== 'ready')
return
if (chunks.value.length > 0) {
const blob = new Blob(chunks.value, { type: recorder.value.mimeType })
const fileReader = new FileReader()
fileReader.onloadend = async () => {
const arrayBuffer = fileReader.result
const decoded = await audioContextRef.value?.decodeAudioData(arrayBuffer as ArrayBuffer)
let audio = decoded?.getChannelData(0)
if ((audio?.length || 0) > MAX_SAMPLES) {
audio = audio?.slice(-MAX_SAMPLES)
}
post({ type: 'generate', data: { audio, language: language.value } })
}
fileReader.readAsArrayBuffer(blob)
}
else {
recorder.value?.requestData()
}
})
watch([language], () => {
recorder.value?.stop()
recorder.value?.start()
})
function handleLoad() {
post({ type: 'load' })
status.value = 'loading'
}
function handleReset() {
recorder.value?.stop()
recorder.value?.start()
}
onUnmounted(() => {
recorder.value?.stop()
recorder.value = undefined
})
</script>
<template>
<main
text="gray-700 dark:gray-200" h-full font-sans
:style="{
paddingTop: `${top}px`,
paddingRight: `${right}px`,
paddingBottom: `${bottom}px`,
paddingLeft: `${left}px`,
}"
>
<div h-full w-full>
<div v-if="IS_WEBGPU_AVAILABLE" class="mx-auto h-screen flex flex-col justify-end bg-white text-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="scrollbar-thin relative h-full flex flex-col items-center justify-center overflow-auto">
<div class="mb-1 max-w-[400px] flex flex-col items-center text-center">
<img src="/logo.png" width="50%" height="auto" class="block">
<h1 class="mb-1 text-4xl font-bold">
Whisper WebGPU
</h1>
<h2 class="text-xl font-semibold">
Real-time in-browser speech recognition
</h2>
</div>
<div class="flex flex-col items-center px-4">
<template v-if="status === null">
<p class="mb-4 max-w-[480px]">
<br>
You are about to load <a href="https://huggingface.co/onnx-community/whisper-base" target="_blank" rel="noreferrer" class="font-medium underline">whisper-base</a>,
a 73 million parameter speech recognition model that is optimized for inference on the web. Once downloaded, the model (~200&nbsp;MB) will be cached and reused when you revisit the page.<br>
<br>
Everything runs directly in your browser using <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noreferrer" class="underline">🤗&nbsp;Transformers.js</a> and ONNX Runtime Web,
meaning no data is sent to a server. You can even disconnect from the internet after the model has loaded!
</p>
<button
class="select-none border rounded-lg bg-blue-400 px-4 py-2 text-white disabled:cursor-not-allowed disabled:bg-blue-100 hover:bg-blue-500"
:disabled="status !== null"
@click="handleLoad"
>
<span>Load model</span>
</button>
</template>
<div class="w-[500px] p-2">
<AudioVisualizer class="w-full rounded-lg" :stream="stream" />
<div v-if="status === 'ready'" class="relative">
<p class="overflow-wrap-anywhere white h-[80px] w-full overflow-y-auto border rounded-lg p-2">
{{ text }}
</p>
<span v-if="tps" class="absolute bottom-0 right-0 px-1">{{ tps.toFixed(2) }} tok/s</span>
</div>
</div>
<div v-if="status === 'ready'" class="relative w-full flex justify-center">
<WhisperLanguageSelect v-model="language" />
<button class="absolute right-2 border rounded-lg px-2" @click="handleReset">
<span>Reset</span>
</button>
</div>
<div v-if="status === 'ready'" class="relative w-full flex flex-col justify-center">
<select v-model="selectedAudioInput" className="border rounded-lg p-2 max-w-[100px]">
<option disabled>
Select a Audio Input
</option>
<option v-for="input of audioInputs" :key="input.deviceId" :value="input.deviceId">
{{ input.label }}
</option>
</select>
</div>
<div v-if="status === 'loading'" class="mx-auto max-w-[500px] w-full p-4 text-left">
<p class="text-center">
{{ loadingMessage }}
</p>
<Progress
v-for="(item, index) of progressItems"
:key="index"
:text="item.file"
:percentage="item.progress || 0"
:total="item.total || 0"
/>
</div>
</div>
</div>
</div>
<div v-else class="fixed z-10 h-screen w-screen flex items-center justify-center bg-black bg-opacity-[92%] text-center text-2xl text-white font-semibold">
WebGPU is not supported<br>by this browser :&#40;
</div>
</div>
</main>
</template>
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = defineProps<{
stream?: MediaStream
}>()
const drawing = ref(false)
const canvasRef = ref<HTMLCanvasElement>()
onMounted(() => {
handleDraw()
})
watch(() => props.stream, () => {
handleDraw()
})
function handleDraw() {
if (drawing.value)
return
if (!canvasRef.value)
return
if (!props.stream)
return
drawing.value = true
const audioContext = new (window.AudioContext || (window as unknown as any).webkitAudioContext)()
const source = audioContext.createMediaStreamSource(props.stream)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 2048
source.connect(analyser)
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
const drawVisual = () => {
try {
if (!canvasRef.value)
return
requestAnimationFrame(drawVisual)
analyser.getByteTimeDomainData(dataArray)
const canvasCtx = canvasRef.value.getContext('2d')
if (!canvasCtx)
return
canvasCtx.fillStyle = 'rgb(255, 255, 255)'
canvasCtx.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
canvasCtx.lineWidth = 2
canvasCtx.strokeStyle = 'rgb(0, 0, 0)'
canvasCtx.beginPath()
const sliceWidth = canvasRef.value.width * 1.0 / bufferLength
let x = 0
for (let i = 0; i < bufferLength; ++i) {
const v = dataArray[i] / 128.0
const y = v * canvasRef.value.height / 2
if (i === 0) {
canvasCtx.moveTo(x, y)
}
else {
canvasCtx.lineTo(x, y)
}
x += sliceWidth
}
canvasCtx.lineTo(canvasRef.value.width, canvasRef.value.height / 2)
canvasCtx.stroke()
}
catch (err) {
console.error(err)
}
}
drawVisual()
}
</script>
<template>
<canvas ref="canvasRef" width="720" height="240" />
</template>
@@ -1,25 +0,0 @@
<script setup lang="ts">
withDefaults(defineProps<{
text: string
percentage?: number
total?: number
}>(), {
percentage: 0,
})
function formatBytes(size?: number) {
if (!size)
size = 0
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return +((size / 1024 ** i).toFixed(2)) * 1 + ['B', 'kB', 'MB', 'GB', 'TB'][i]
}
</script>
<template>
<div className="w-full bg-gray-100 dark:bg-gray-700 text-left rounded-lg overflow-hidden mb-0.5">
<div className="bg-blue-400 whitespace-nowrap px-1 text-sm" :style="{ width: `${percentage}%` }">
{{ text }} ({{ percentage.toFixed(2) }}%{{ Number.isNaN(total) ? '' : ` of ${formatBytes(total)}` }})
</div>
</div>
</template>
@@ -1,129 +0,0 @@
<script setup lang="ts">
const language = defineModel({ type: String, required: true })
// List of supported languages:
// https://help.openai.com/en/articles/7031512-whisper-api-faq
// https://github.com/openai/whisper/blob/248b6cb124225dd263bb9bd32d060b6517e067f8/whisper/tokenizer.py#L79
const LANGUAGES = {
en: 'english',
zh: 'chinese',
de: 'german',
es: 'spanish/castilian',
ru: 'russian',
ko: 'korean',
fr: 'french',
ja: 'japanese',
pt: 'portuguese',
tr: 'turkish',
pl: 'polish',
ca: 'catalan/valencian',
nl: 'dutch/flemish',
ar: 'arabic',
sv: 'swedish',
it: 'italian',
id: 'indonesian',
hi: 'hindi',
fi: 'finnish',
vi: 'vietnamese',
he: 'hebrew',
uk: 'ukrainian',
el: 'greek',
ms: 'malay',
cs: 'czech',
ro: 'romanian/moldavian/moldovan',
da: 'danish',
hu: 'hungarian',
ta: 'tamil',
no: 'norwegian',
th: 'thai',
ur: 'urdu',
hr: 'croatian',
bg: 'bulgarian',
lt: 'lithuanian',
la: 'latin',
mi: 'maori',
ml: 'malayalam',
cy: 'welsh',
sk: 'slovak',
te: 'telugu',
fa: 'persian',
lv: 'latvian',
bn: 'bengali',
sr: 'serbian',
az: 'azerbaijani',
sl: 'slovenian',
kn: 'kannada',
et: 'estonian',
mk: 'macedonian',
br: 'breton',
eu: 'basque',
is: 'icelandic',
hy: 'armenian',
ne: 'nepali',
mn: 'mongolian',
bs: 'bosnian',
kk: 'kazakh',
sq: 'albanian',
sw: 'swahili',
gl: 'galician',
mr: 'marathi',
pa: 'punjabi/panjabi',
si: 'sinhala/sinhalese',
km: 'khmer',
sn: 'shona',
yo: 'yoruba',
so: 'somali',
af: 'afrikaans',
oc: 'occitan',
ka: 'georgian',
be: 'belarusian',
tg: 'tajik',
sd: 'sindhi',
gu: 'gujarati',
am: 'amharic',
yi: 'yiddish',
lo: 'lao',
uz: 'uzbek',
fo: 'faroese',
ht: 'haitian creole/haitian',
ps: 'pashto/pushto',
tk: 'turkmen',
nn: 'nynorsk',
mt: 'maltese',
sa: 'sanskrit',
lb: 'luxembourgish/letzeburgesch',
my: 'myanmar/burmese',
bo: 'tibetan',
tl: 'tagalog',
mg: 'malagasy',
as: 'assamese',
tt: 'tatar',
haw: 'hawaiian',
ln: 'lingala',
ha: 'hausa',
ba: 'bashkir',
jw: 'javanese',
su: 'sundanese',
}
function titleCase(str: string) {
str = str.toLowerCase()
return (str.match(/\w+.?/g) || [])
.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join('')
}
const names = Object.values(LANGUAGES).map(titleCase)
</script>
<template>
<div>
<select v-model="language" className="border rounded-lg p-2 max-w-[100px]">
<option v-for="(name, index) of Object.keys(LANGUAGES)" :key="index" :value="name">
{{ names[index] }}
</option>
</select>
</div>
</template>
-66
View File
@@ -1,66 +0,0 @@
export interface EventLoading {
status: 'loading'
data: string
}
export interface EventInitiate {
status: 'initiate'
name: string
file: string
// Not used
progress?: number
loaded?: number
total?: number
}
export interface EventDownload {
status: 'download'
name: string
file: string
// Not used
progress?: number
loaded?: number
total?: number
}
export interface EventProgress {
status: 'progress'
name: string
file: string
progress: number
loaded: number
total: number
}
export interface EventDone {
status: 'done'
name: string
file: string
// Not used
progress?: number
loaded?: number
total?: number
}
export interface EventReady {
status: 'ready'
}
export interface EventStart {
status: 'start'
}
export interface EventUpdate {
status: 'update'
tps: number
output: string
numTokens: number
}
export interface EventComplete {
status: 'complete'
output: string[]
}
export type MessageEvents = EventLoading | EventInitiate | EventDownload | EventProgress | EventDone | EventReady | EventStart | EventUpdate | EventComplete
export type ProgressMessageEvents = EventInitiate | EventProgress | EventDone
-150
View File
@@ -1,150 +0,0 @@
/* eslint-disable no-restricted-globals */
import type {
ModelOutput,
PreTrainedTokenizer,
Processor,
ProgressCallback,
Tensor,
WhisperModel,
} from '@huggingface/transformers'
import {
AutoProcessor,
AutoTokenizer,
full,
TextStreamer,
WhisperForConditionalGeneration,
} from '@huggingface/transformers'
const MAX_NEW_TOKENS = 64
/**
* This class uses the Singleton pattern to ensure that only one instance of the model is loaded.
*/
class AutomaticSpeechRecognitionPipeline {
static model_id: string | null = null
static tokenizer: Promise<PreTrainedTokenizer>
static processor: Promise<Processor>
static model: Promise<WhisperModel>
static async getInstance(progress_callback?: ProgressCallback) {
this.model_id = 'onnx-community/whisper-base'
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
})
this.processor ??= AutoProcessor.from_pretrained(this.model_id, {
progress_callback,
})
this.model ??= WhisperForConditionalGeneration.from_pretrained(this.model_id, {
dtype: {
encoder_model: 'fp32', // 'fp16' works too
decoder_model_merged: 'q4', // or 'fp32' ('fp16' is broken)
},
device: 'webgpu',
progress_callback,
}) as Promise<unknown> as Promise<WhisperModel>
return Promise.all([this.tokenizer, this.processor, this.model])
}
}
let processing = false
async function generate({ audio, language }: { audio: ArrayBuffer, language: string }) {
if (processing)
return
processing = true
// Tell the main thread we are starting
self.postMessage({ status: 'start' })
// Retrieve the text-generation pipeline.
const [tokenizer, processor, model] = await AutomaticSpeechRecognitionPipeline.getInstance()
let startTime
let numTokens = 0
const callback_function = (output: ModelOutput | Tensor) => {
startTime ??= performance.now()
let tps
if (numTokens++ > 0) {
tps = numTokens / (performance.now() - startTime) * 1000
}
self.postMessage({
status: 'update',
output,
tps,
numTokens,
})
}
const streamer = new TextStreamer(tokenizer, {
skip_prompt: true,
decode_kwargs: {
skip_special_tokens: true,
},
callback_function,
})
const inputs = await processor(audio)
const outputs = await model.generate({
...inputs,
max_new_tokens: MAX_NEW_TOKENS,
language,
streamer,
})
const outputText = tokenizer.batch_decode(outputs as Tensor, { skip_special_tokens: true })
// Send the output back to the main thread
self.postMessage({
status: 'complete',
output: outputText,
})
processing = false
}
async function load() {
self.postMessage({
status: 'loading',
data: 'Loading model...',
})
// Load the pipeline and save it for future use.
const [_tokenizer, _processor, model] = await AutomaticSpeechRecognitionPipeline.getInstance((x) => {
// We also add a progress callback to the pipeline so that we can
// track model loading.
self.postMessage(x)
})
self.postMessage({
status: 'loading',
data: 'Compiling shaders and warming up model...',
})
// Run model with dummy input to compile shaders
await model.generate({
input_features: full([1, 80, 3000], 0.0),
max_new_tokens: 1,
} as Record<string, unknown>)
self.postMessage({ status: 'ready' })
}
// Listen for messages from the main thread
self.addEventListener('message', async (e) => {
const { type, data } = e.data
switch (type) {
case 'load':
load()
break
case 'generate':
generate(data)
break
}
})
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import './styles/main.css'
import 'uno.css'
createApp(App)
.mount('#app')
-10
View File
@@ -1,10 +0,0 @@
declare interface Window {
// extend the window
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
-39
View File
@@ -1,39 +0,0 @@
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
html.dark {
background: #121212;
color-scheme: dark;
}
.scrollbar-thin::-webkit-scrollbar {
@apply w-2;
}
.scrollbar-thin::-webkit-scrollbar-track {
@apply rounded-full bg-gray-100 dark:bg-gray-700;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-300 dark:bg-gray-600;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
-29
View File
@@ -1,29 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"DOM",
"ESNext",
"WebWorker"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": [
"vitest",
"vite/client",
"@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"]
}
-35
View File
@@ -1,35 +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',
cute: 'Kiwi Maru',
cuteen: 'Sniglet',
},
}),
presetIcons({
scale: 1.2,
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
})
-28
View File
@@ -1,28 +0,0 @@
import Vue from '@vitejs/plugin-vue'
import { LFS, SpaceCard } from 'hfup/vite'
import Unocss from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
Vue(),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
// HuggingFace Spaces
LFS(),
SpaceCard({
title: 'Real-time Whisper WebGPU (Vue)',
emoji: '🎤',
colorFrom: 'blue',
colorTo: 'blue',
sdk: 'static',
pinned: false,
license: 'mit',
models: ['onnx-community/whisper-base'],
short_description: 'Yet another Real-time Whisper with WebGPU, written in Vue',
thumbnail: 'https://raw.githubusercontent.com/moeru-ai/airi/refs/heads/main/packages/whisper-webgpu/public/banner.png',
}),
],
})
-76
View File
@@ -1,76 +0,0 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { importDirectory } from '@iconify/tools'
import { getPackageInfo, isPackageExists } from 'local-pkg'
import { defineBuildConfig } from 'unbuild'
import packageJSON from './package.json'
function json(any: any) {
return JSON.stringify(any, null, 2)
}
export default defineBuildConfig({
entries: [
{ builder: 'rollup', input: 'src/index.ts', outDir: 'dist', declaration: true },
{ builder: 'mkdist', input: './src', outDir: './dist', pattern: ['**/*.json'] },
],
externals: [
'./metadata.json',
'./icons.json',
'./chars.json',
'./info.json',
],
rollup: {
emitCJS: true,
},
declaration: true,
sourcemap: false,
failOnWarn: false,
hooks: {
'build:done': async () => {
if (!isPackageExists('@lobehub/icons-static-svg'))
throw new Error('Package @lobehub/icons-static-svg not found')
const pkg = await getPackageInfo('@lobehub/icons-static-svg')
if (!pkg)
throw new Error('Package @lobehub/icons-static-svg not found')
const iconSetData = await importDirectory(join(pkg.rootPath, 'icons'), { prefix: 'lobe-icons', ignoreImportErrors: 'warn' })
const iconJSONData = iconSetData.export()
await writeFile('./dist/metadata.json', json({ categories: iconSetData.categories }), { encoding: 'utf8' })
await writeFile('./dist/icons.json', json(iconJSONData), { encoding: 'utf8' })
await writeFile('./dist/chars.json', json({}), { encoding: 'utf8' })
await writeFile('./dist/info.json', json({
prefix: 'lobe-icons',
name: 'Lobe Icons',
total: Object.keys(iconJSONData.icons).length,
version: packageJSON.version,
author: {
name: packageJSON.author.name,
url: packageJSON.author.url,
},
license: {
title: 'MIT',
spdx: 'MIT',
},
samples: [
'openai',
'deepseek',
'claude',
],
height: 20,
displayHeight: 20,
category: 'Logos 20px',
tags: [
'AI',
'Models',
'LLM',
'Lobe',
],
palette: false,
}), { encoding: 'utf8' })
},
},
})
-46
View File
@@ -1,46 +0,0 @@
{
"name": "@proj-airi/lobe-icons",
"type": "module",
"version": "0.3.6",
"description": "Iconify JSON IconSet port for @lobehub/icons",
"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": "packages/lobe-icons"
},
"exports": {
"./*": "./*",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./icons.json": "./dist/icons.json",
"./info.json": "./dist/info.json",
"./metadata.json": "./dist/metadata.json"
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"dev": "unbuild",
"stub": "unbuild",
"build": "unbuild"
},
"devDependencies": {
"@iconify/tools": "^4.1.1",
"@lobehub/icons-static-svg": "^1.30.0",
"local-pkg": "^1.1.1"
}
}
-15
View File
@@ -1,15 +0,0 @@
// @ts-expect-error - This is a generated file
import chars from './chars.json'
// @ts-expect-error - This is a generated file
import icons from './icons.json'
// @ts-expect-error - This is a generated file
import info from './info.json'
// @ts-expect-error - This is a generated file
import metadata from './metadata.json'
export {
chars,
icons,
info,
metadata,
}
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"types": [
"vite/client"
],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
+79 -533
View File
File diff suppressed because it is too large Load Diff