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