refactor(apps|packages): migrate out moonshine-web, whisper-webgpu, lobe-icons to @proj-airi
This commit is contained in:
@@ -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: |-
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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;"]
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(' '),
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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:^",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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;"]
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 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">🤗 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 :(
|
||||
</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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
Vendored
-10
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(' '),
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -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' })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+79
-533
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user