refactor(apps): move all apps into apps directory

This commit is contained in:
Neko Ayaka
2025-02-20 13:14:23 +08:00
parent 2a3e290334
commit 03d74df7e8
147 changed files with 319 additions and 703 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine as build-stage
WORKDIR /app
RUN corepack enable
COPY .npmrc package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+57
View File
@@ -0,0 +1,57 @@
<h1 align="center">Moonshine Web (Vue)</h1>
<p align="center">
[<a href="https://moonshine-web-vue.netlify.app/">Try it</a>]
</p>
> Heavily inspired by [Realtime in-browser speech recognition](https://huggingface.co/spaces/webml-community/moonshine-web)
# Moonshine Web
A simple Vue + Vite application for running [Moonshine Base](https://huggingface.co/onnx-community/moonshine-base-ONNX), a powerful speech-to-text model optimized for fast and accurate automatic speech recognition (ASR) on resource-constrained devices. It runs locally in the browser using Transformers.js and WebGPU-acceleration (or WASM as a fallback).
## Getting Started
Follow the steps below to set up and run the application.
### 1. Clone the Repository
Clone the examples repository from GitHub:
```sh
git clone https://github.com/moeru-ai/airi.git
```
### 2. Navigate to the Project Directory
Change your working directory to the `moonshine-web` folder:
```sh
cd packages/moonshine-web
```
### 3. Install Dependencies
Install the necessary dependencies using npm:
```sh
npm i
```
### 4. Run the Development Server
Start the development server:
```sh
npm run dev
```
The application should now be running locally. Open your browser and go to `http://localhost:5175` to see it in action.
## Acknowledgements
The audio visualizer was adapted from Wael Yasmina's [amazing tutorial](https://waelyasmina.net/articles/how-to-create-a-3d-audio-visualizer-using-three-js/).
Great thanks to what Xenova have done.
> [Source code](https://github.com/huggingface/transformers.js-examples/tree/38a883dd465d70d7368b86b95aa0678895ca4e83/moonshine-web)
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Moonshine Web (Vue)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/png" href="/logo.png" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
</head>
<body class="font-sans">
<script id="vertexshader" type="vertex">
uniform float u_time;
vec3 mod289(vec3 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 mod289(vec4 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.0)+10.0)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
vec3 fade(vec3 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise, periodic variant
float pnoise(vec3 P, vec3 rep)
{
vec3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
vec3 Pi1 = mod(Pi0 + vec3(1.0), rep); // Integer part + 1, mod period
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 * (1.0 / 7.0);
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 * (1.0 / 7.0);
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}
uniform float u_frequency;
void main() {
float noise = 3.0 * pnoise(position + u_time, vec3(10.0));
float displacement = (u_frequency / 30.) * (noise / 10.);
vec3 newPosition = position + normal * displacement;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
</script>
<script id="fragmentshader" type="fragment">
uniform float u_red;
uniform float u_blue;
uniform float u_green;
void main() {
gl_FragColor = vec4(vec3(u_red, u_green, u_blue), 1. );
}
</script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
[build]
publish = "apps/moonshine-web/dist"
command = "pnpm run packages:stub && pnpm -F @proj-airi/moonshine-web run build"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
+37
View File
@@ -0,0 +1,37 @@
{
"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": "^65.5.0",
"@vueuse/core": "^12.7.0",
"@vueuse/motion": "^2.2.6",
"ofetch": "^1.4.1",
"three": "^0.173.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@huggingface/transformers": "^3.3.3",
"@types/audioworklet": "^0.0.70",
"@types/three": "^0.173.0",
"@vitejs/plugin-vue": "^5.2.1",
"@webgpu/types": "^0.1.54",
"hfup": "workspace:^",
"vue-tsc": "^2.2.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+280
View File
@@ -0,0 +1,280 @@
<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>
@@ -0,0 +1,62 @@
<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>
+53
View File
@@ -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,
)
+40
View File
@@ -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)
+59
View File
@@ -0,0 +1,59 @@
export enum MessageType {
Status = 'status',
Output = 'output',
Info = 'info',
Request = 'request',
Error = 'error',
Load = 'load',
}
export enum MessageStatus {
RecordingStart = 'recording_start',
RecordingEnd = 'recording_end',
Ready = 'ready',
}
export enum Duration {
UntilNext = 'until_next',
}
export interface MessageEventStatus {
type: MessageType.Status
status: MessageStatus
message: string
duration?: Duration
}
export interface MessageEventOutput {
type: MessageType.Output
buffer: Float32Array<any>
message: string
start: number
end: number
duration: number
}
export interface MessageEventInfo {
type: MessageType.Info
message: string
duration?: Duration.UntilNext
}
export interface MessageEventBufferRequest {
type: MessageType.Request
buffer: Float32Array<any>
message?: string
}
export interface MessageEventError {
type: MessageType.Error
error: unknown
message?: string
}
export interface MessageEventLoad {
type: MessageType.Load
message?: string
}
export type MessageEvent = MessageEventError | MessageEventStatus | MessageEventOutput | MessageEventInfo | MessageEventBufferRequest | MessageEventLoad
+275
View File
@@ -0,0 +1,275 @@
/* eslint-disable no-restricted-globals */
import type { AutomaticSpeechRecognitionPipeline, PreTrainedModel } from '@huggingface/transformers'
import type { MessageEvent as InternalMessageEvent, MessageEventBufferRequest, MessageEventError, MessageEventInfo, MessageEventOutput, MessageEventStatus } from './types'
import { AutoModel, pipeline, Tensor } from '@huggingface/transformers'
import {
EXIT_THRESHOLD,
MAX_BUFFER_DURATION,
MAX_NUM_PREV_BUFFERS,
MIN_SILENCE_DURATION_SAMPLES,
MIN_SPEECH_DURATION_SAMPLES,
SAMPLE_RATE,
SPEECH_PAD_SAMPLES,
SPEECH_THRESHOLD,
} from '../constants'
import { supportsWebGPU } from '../utils'
import { Duration, MessageStatus, MessageType } from './types'
export type DType = Record<string, Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['dtype']>, string>[string]>
export type Device = Extract<Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['device']>, Record<string, any>>, 'webgpu' | 'wasm'>
export type PretrainedConfig = NonNullable<Parameters<typeof AutoModel.from_pretrained>[1]>['config']
// Load models
let silero_vad: PreTrainedModel
let transcriber: AutomaticSpeechRecognitionPipeline
// Transformers.js currently doesn't support simultaneous inference,
// so we need to chain the inference promises.
let inferenceChain = Promise.resolve()
// Global audio buffer to store incoming audio
const BUFFER = new Float32Array(MAX_BUFFER_DURATION * SAMPLE_RATE)
let bufferPointer = 0
// Initial state for VAD
const sr = new Tensor('int64', [SAMPLE_RATE], [])
let state = new Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128])
// Whether we are in the process of adding audio to the buffer
let isRecording = false
// Track the number of samples after the last speech chunk
let postSpeechSamples = 0
const DEVICE_DTYPE_CONFIGS: Record<Device, DType> = {
webgpu: {
encoder_model: 'fp32',
decoder_model_merged: 'q4',
},
wasm: {
encoder_model: 'fp32',
decoder_model_merged: 'q8',
},
}
async function newVADModel() {
// Load models
return await AutoModel.from_pretrained(
'onnx-community/silero-vad',
{
config: { model_type: 'custom' } as PretrainedConfig,
dtype: 'fp32', // Full-precision
},
).catch((error) => {
self.postMessage({ type: MessageType.Error, error } satisfies MessageEventError)
throw error
})
}
async function newAutomaticSpeechRecognitionPipeline(device: Device) {
return await pipeline(
'automatic-speech-recognition',
'onnx-community/moonshine-base-ONNX', // or "onnx-community/whisper-tiny.en",
{
device,
dtype: DEVICE_DTYPE_CONFIGS[device],
},
).catch((error) => {
self.postMessage({ type: MessageType.Error, error } satisfies MessageEventError)
throw error
})
}
/**
* Perform Voice Activity Detection (VAD)
* @param {Float32Array} buffer The new audio buffer
* @returns {Promise<boolean>} `true` if the buffer is speech, `false` otherwise.
*/
async function vad(buffer: Float32Array<ArrayBuffer>) {
if (silero_vad === undefined) {
console.warn('VAD model not loaded yet')
return false
}
const input = new Tensor('float32', buffer, [1, buffer.length])
const { stateN, output } = await (inferenceChain = inferenceChain.then(_ => silero_vad({ input, sr, state })))
state = stateN // Update state
const isSpeech = output.data[0]
// Use heuristics to determine if the buffer is speech or not
return (
// Case 1: We are above the threshold (definitely speech)
isSpeech > SPEECH_THRESHOLD
// Case 2: We are in the process of recording, and the probability is above the negative (exit) threshold
|| (isRecording && isSpeech >= EXIT_THRESHOLD)
)
}
/**
* Transcribe the audio buffer
* @param {Float32Array} buffer The audio buffer
* @param {object} data Additional data
* @param {number} data.start The start time of the speech segment
* @param {number} data.end The end time of the speech segment
* @param {number} data.duration The duration of the speech segment
*/
async function transcribe(buffer: Float32Array<any>, data: { start: number, end: number, duration: number }) {
if (transcriber === undefined) {
console.warn('Transcriber model not loaded yet')
return
}
// @ts-expect-error - chain
const { text }: { text: string } = await (inferenceChain = inferenceChain.then(_ => transcriber(buffer)))
self.postMessage({ type: MessageType.Output, buffer, message: text, ...data } satisfies MessageEventOutput)
}
function reset(offset = 0) {
self.postMessage({
type: MessageType.Status,
status: MessageStatus.RecordingEnd,
message: 'Transcribing...',
duration: Duration.UntilNext,
} satisfies MessageEventStatus)
BUFFER.fill(0, offset)
bufferPointer = offset
isRecording = false
postSpeechSamples = 0
}
const prevBuffers: Array<Float32Array<ArrayBuffer>> = []
function dispatchForTranscriptionAndResetAudioBuffer(overflow?: Float32Array<ArrayBuffer>) {
// Get start and end time of the speech segment, minus the padding
const now = Date.now()
const end
= now - ((postSpeechSamples + SPEECH_PAD_SAMPLES) / SAMPLE_RATE) * 1000
const start = end - (bufferPointer / SAMPLE_RATE) * 1000
const duration = end - start
const overflowLength = overflow?.length ?? 0
// Send the audio buffer to the worker
const buffer = BUFFER.slice(0, bufferPointer + SPEECH_PAD_SAMPLES)
const prevLength = prevBuffers.reduce((acc, b) => acc + b.length, 0)
const paddedBuffer = new Float32Array<any>(prevLength + buffer.length)
let offset = 0
for (const prev of prevBuffers) {
paddedBuffer.set(prev, offset)
offset += prev.length
}
paddedBuffer.set(buffer, offset)
transcribe(paddedBuffer, { start, end, duration })
// Set overflow (if present) and reset the rest of the audio buffer
if (overflow) {
BUFFER.set(overflow, 0)
}
reset(overflowLength)
}
async function load() {
const device = (await supportsWebGPU()) ? 'webgpu' : 'wasm'
self.postMessage({ type: MessageType.Info, message: `Using device: "${device}"` } satisfies MessageEventInfo)
self.postMessage({
type: MessageType.Info,
message: 'Loading models...',
duration: Duration.UntilNext,
} satisfies MessageEventInfo)
// Load models
silero_vad = await newVADModel()
transcriber = await newAutomaticSpeechRecognitionPipeline(device)
await transcriber(new Float32Array(SAMPLE_RATE)) // Compile shaders
self.postMessage({ type: 'status', status: 'ready', message: 'Ready!' })
self.onmessage = async (event) => {
const { buffer } = event.data as MessageEventBufferRequest
const wasRecording = isRecording // Save current state
const isSpeech = await vad(buffer)
if (!wasRecording && !isSpeech) {
// We are not recording, and the buffer is not speech,
// so we will probably discard the buffer. So, we insert
// into a FIFO queue with maximum size of PREV_BUFFER_SIZE
if (prevBuffers.length >= MAX_NUM_PREV_BUFFERS) {
// If the queue is full, we discard the oldest buffer
prevBuffers.shift()
}
prevBuffers.push(buffer)
return
}
const remaining = BUFFER.length - bufferPointer
if (buffer.length >= remaining) {
// The buffer is larger than (or equal to) the remaining space in the global buffer,
// so we perform transcription and copy the overflow to the global buffer
BUFFER.set(buffer.subarray(0, remaining), bufferPointer)
bufferPointer += remaining
// Dispatch the audio buffer
const overflow = buffer.subarray(remaining)
dispatchForTranscriptionAndResetAudioBuffer(overflow)
return
}
else {
// The buffer is smaller than the remaining space in the global buffer,
// so we copy it to the global buffer
BUFFER.set(buffer, bufferPointer)
bufferPointer += buffer.length
}
if (isSpeech) {
if (!isRecording) {
// Indicate start of recording
self.postMessage({
type: MessageType.Status,
status: MessageStatus.RecordingStart,
message: 'Listening...',
duration: Duration.UntilNext,
} satisfies MessageEventStatus)
}
// Start or continue recording
isRecording = true
postSpeechSamples = 0 // Reset the post-speech samples
return
}
postSpeechSamples += buffer.length
// At this point we're confident that we were recording (wasRecording === true), but the latest buffer is not speech.
// So, we check whether we have reached the end of the current audio chunk.
if (postSpeechSamples < MIN_SILENCE_DURATION_SAMPLES) {
// There was a short pause, but not long enough to consider the end of a speech chunk
// (e.g., the speaker took a breath), so we continue recording
return
}
if (bufferPointer < MIN_SPEECH_DURATION_SAMPLES) {
// The entire buffer (including the new chunk) is smaller than the minimum
// duration of a speech chunk, so we can safely discard the buffer.
reset()
return
}
dispatchForTranscriptionAndResetAudioBuffer()
}
}
self.addEventListener('message', (event) => {
const { type } = event.data as InternalMessageEvent
switch (type) {
case MessageType.Load:
load()
break
}
})
+14
View File
@@ -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')
+18
View File
@@ -0,0 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
* {
font-family: 'Poppins', sans-serif;
}
html {
overflow: hidden;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
+26
View File
@@ -0,0 +1,26 @@
export function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleString('zh', {
hour12: false,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
fractionalSecondDigits: 3,
})
}
export async function supportsWebGPU() {
try {
if (!('gpu' in navigator) || !navigator.gpu)
return false
await navigator.gpu.requestAdapter()
return true
}
catch (e) {
console.error(e)
return false
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"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"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, mergeConfigs, presetIcons, 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',
},
}),
presetIcons({
scale: 1.2,
}),
],
},
]))
+33
View File
@@ -0,0 +1,33 @@
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' },
})
+38
View File
@@ -0,0 +1,38 @@
# たまごっち アイリ
A desktop application for たまごっち アイリ.
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ cd /packages/stage
$ pnpm dev:tamagotchi
```
Then open another terminal and run:
```bash
$ cd /packages/tamagotchi
$ pnpm dev:tamagotchi
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,43 @@
appId: com.github.moeru-ai.airi-tamagotchi
productName: airi
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: tamagotchi
nsis:
artifactName: ${productName}-${os}-${arch}-v${version}.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
# - NSCameraUseContinuityCameraDeviceType: Application requests access to the device's camera.
# - AVCaptureDeviceTypeContinuityCamera: Application requests access to the device's camera.
notarize: false
dmg:
artifactName: ${productName}-${os}-${arch}-v${version}.${ext}
linux:
target:
- AppImage
maintainer: github.com/moeru-ai Contributors
category: Entertainment
appImage:
artifactName: ${productName}-${os}-${arch}-v${version}.${ext}
npmRebuild: false
# publish:
# provider: generic
# url: https://example.com/auto-updates
@@ -0,0 +1,13 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import rendererConfig from './renderer.vite.config'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: rendererConfig,
})
+120
View File
@@ -0,0 +1,120 @@
{
"name": "@proj-airi/stage-tamagotchi",
"version": "0.2.0",
"private": true,
"description": "An Electron application with Vue and TypeScript",
"author": "LemonNekoGH",
"homepage": "https://electron-vite.org",
"main": "./out/main/index.js",
"scripts": {
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux"
},
"dependencies": {
"@11labs/client": "^0.0.7",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@formkit/auto-animate": "^0.8.2",
"@gcornut/valibot-json-schema": "^0.42.0",
"@huggingface/transformers": "^3.3.3",
"@pixi/app": "6",
"@pixi/constants": "6",
"@pixi/core": "6",
"@pixi/display": "6",
"@pixi/extensions": "6",
"@pixi/interaction": "6",
"@pixi/loaders": "6",
"@pixi/math": "6",
"@pixi/runner": "6",
"@pixi/settings": "6",
"@pixi/sprite": "6",
"@pixi/ticker": "6",
"@pixi/utils": "6",
"@pixiv/three-vrm": "^3.3.4",
"@pixiv/three-vrm-animation": "^3.3.4",
"@pixiv/three-vrm-core": "^3.3.4",
"@proj-airi/stage-ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.22",
"@tresjs/cientos": "^4.1.0",
"@tresjs/core": "^4.3.3",
"@types/yauzl": "^2.10.3",
"@typeschema/valibot": "^0.14.0",
"@unhead/vue": "^1.11.19",
"@unocss/reset": "^65.5.0",
"@vueuse/core": "^12.7.0",
"@vueuse/head": "^2.0.0",
"@vueuse/shared": "^12.7.0",
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/model": "catalog:",
"@xsai/providers": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/utils-chat": "^0.1.0-beta.5",
"defu": "^6.1.4",
"nprogress": "^0.2.0",
"ofetch": "^1.4.1",
"onnxruntime-web": "^1.20.1",
"pinia": "^3.0.1",
"pixi-filters": "^4.2.0",
"pixi-live2d-display": "^0.4.0",
"popmotion": "^11.0.5",
"rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"shiki": "^2.4.2",
"three": "^0.173.0",
"unified": "^11.0.5",
"valibot": "1.0.0-beta.9",
"vaul-vue": "^0.2.1",
"vue": "^3.5.13",
"vue-demi": "^0.14.10",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0",
"yauzl": "^3.2.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@iconify-json/carbon": "^1.2.7",
"@iconify-json/eos-icons": "^1.2.2",
"@iconify-json/lucide": "^1.2.26",
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/svg-spinners": "^1.2.2",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@proj-airi/elevenlabs": "workspace:^",
"@proj-airi/unplugin-download": "workspace:^",
"@proj-airi/unplugin-live2d-sdk": "workspace:^",
"@shikijs/markdown-it": "^2.4.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/nprogress": "^0.2.3",
"@types/three": "^0.173.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue-macros/volar": "^0.30.14",
"@vueuse/motion": "^2.2.6",
"electron": "^34.2.0",
"electron-builder": "24.13.3",
"electron-vite": "^2.3.0",
"markdown-it-link-attributes": "^4.0.1",
"unplugin-auto-import": "^19.1.0",
"unplugin-vue-components": "^28.2.0",
"unplugin-vue-macros": "^2.14.2",
"unplugin-vue-markdown": "^28.3.0",
"unplugin-vue-router": "^0.11.2",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-tsc": "^2.2.2"
}
}
@@ -0,0 +1,41 @@
import { join, resolve } from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { Download } from '@proj-airi/unplugin-download'
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk'
import Vue from '@vitejs/plugin-vue'
import UnoCss from 'unocss/vite'
import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
exclude: [
'@proj-airi/stage-ui/*',
],
},
resolve: {
alias: {
'@renderer': resolve(join('src', 'renderer', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'dist')),
'@proj-airi/stage-ui/stores': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'dist', 'stores')),
},
},
plugins: [
Vue(),
UnoCss(),
VueRouter({
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
routesFolder: 'src/renderer/src/pages',
}),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [resolve(import.meta.dirname, 'src', 'renderer', 'locales/**')],
}),
DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'assets/live2d/models'),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'assets/live2d/models'),
],
})
+312
View File
@@ -0,0 +1,312 @@
import { join } from 'node:path'
import { env, platform } from 'node:process'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import { inertia } from 'popmotion'
import icon from '../../build/icon.png?asset'
let globalMouseTracker: ReturnType<typeof setInterval> | null = null
let mainWindow: BrowserWindow
let currentAnimationX: { stop: () => void } | null = null
let currentAnimationY: { stop: () => void } | null = null
let isDragging = false
let lastMousePosition = { x: 0, y: 0 }
let lastMouseTime = Date.now()
let currentVelocity = { x: 0, y: 0 }
let dragOffset = { x: 0, y: 0 }
function createWindow(): void {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 300 * 1.5,
height: 400 * 1.5,
show: false,
autoHideMenuBar: true,
frame: false,
transparent: true,
resizable: false,
hasShadow: false,
alwaysOnTop: true,
...(platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(import.meta.dirname, '..', 'preload', 'index.js'),
sandbox: false,
},
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
mainWindow.show()
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(env.ELECTRON_RENDERER_URL)
}
else {
mainWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'))
}
ipcMain.on('start-window-drag', (_) => {
isDragging = true
const mousePos = screen.getCursorScreenPoint()
const [windowX, windowY] = mainWindow.getPosition()
// Calculate the offset between cursor and window position
dragOffset = {
x: mousePos.x - windowX,
y: mousePos.y - windowY,
}
// Stop any existing animations
if (currentAnimationX) {
currentAnimationX.stop()
currentAnimationX = null
}
if (currentAnimationY) {
currentAnimationY.stop()
currentAnimationY = null
}
// Initialize last position for velocity tracking
lastMousePosition = { x: mousePos.x, y: mousePos.y }
lastMouseTime = Date.now()
currentVelocity = { x: 0, y: 0 }
// Start global mouse tracking
if (!globalMouseTracker) {
globalMouseTracker = setInterval(() => {
const mousePos = screen.getCursorScreenPoint()
if (isDragging) {
handleWindowMove(mousePos.x, mousePos.y)
}
}, 16) // ~60fps
}
})
ipcMain.on('end-window-drag', () => {
isDragging = false
if (globalMouseTracker) {
clearInterval(globalMouseTracker)
globalMouseTracker = null
}
// Apply inertia animation when drag ends
const [currentX, currentY] = mainWindow.getPosition()
let latestX = currentX
let latestY = currentY
const inertiaConfig = {
power: 0.4, // Reduced from 0.6 for stronger resistance
timeConstant: 250, // Reduced from 400 for quicker deceleration
modifyTarget: (v: number) => v,
min: 0,
max: Infinity,
}
// Clamp velocity to reasonable values
const clampVelocity = (v: number) => {
const maxVelocity = 500 // Reduced from 800 for less momentum
const minVelocity = -500
return Math.min(Math.max(v, minVelocity), maxVelocity)
}
// Reduce velocity amplification and clamp values
const amplifiedVelocity = {
x: clampVelocity(currentVelocity.x * 0.2), // Reduced from 0.3 for less momentum
y: clampVelocity(currentVelocity.y * 0.2),
}
// Ignore very small movements
if (Math.abs(amplifiedVelocity.x) > 35 || Math.abs(amplifiedVelocity.y) > 35) {
currentAnimationX = inertia({
from: currentX,
velocity: amplifiedVelocity.x,
...inertiaConfig,
onUpdate: (x) => {
latestX = Math.round(x)
mainWindow.setPosition(latestX, Math.round(latestY))
},
onComplete: () => {
currentAnimationX = null
},
})
currentAnimationY = inertia({
from: currentY,
velocity: amplifiedVelocity.y,
...inertiaConfig,
onUpdate: (y) => {
latestY = Math.round(y)
mainWindow.setPosition(Math.round(latestX), latestY)
},
onComplete: () => {
currentAnimationY = null
},
})
}
})
ipcMain.on('move-window', (_, cursorX: number, cursorY: number) => {
handleWindowMove(cursorX, cursorY)
})
}
let settingsWindow: BrowserWindow | null = null
function createSettingsWindow(): void {
if (settingsWindow) {
settingsWindow.show()
return
}
settingsWindow = new BrowserWindow({
width: 300 * 2,
height: 400 * 2,
show: false,
webPreferences: {
preload: join(import.meta.dirname, '..', 'preload', 'index.js'),
sandbox: false,
},
})
settingsWindow.on('ready-to-show', () => {
settingsWindow?.show()
})
settingsWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
settingsWindow.on('close', () => {
settingsWindow = null
})
settingsWindow.show()
if (is.dev && env.ELECTRON_RENDERER_URL) {
settingsWindow.loadURL(join(env.ELECTRON_RENDERER_URL, '#/settings'))
}
else {
settingsWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'), {
hash: '/settings',
})
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Menu
const menu = Menu.buildFromTemplate([
{
label: 'airi',
role: 'appMenu',
submenu: [
{
role: 'about',
},
{
role: 'toggleDevTools',
},
{
label: 'Settings',
click: () => createSettingsWindow(),
},
{
label: 'Quit',
click: () => app.quit(),
},
],
},
])
Menu.setApplicationMenu(menu)
// Set app user model id for windows
electronApp.setAppUserModelId('com.github.moeru-ai.airi-tamagotchi')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
// TODO: i18n
ipcMain.on('quit', () => {
dialog.showMessageBox({
type: 'info',
title: 'Quit',
message: 'Are you sure you want to quit?',
buttons: ['Quit', 'Cancel'],
}).then((result) => {
if (result.response === 0) {
app.quit()
}
})
})
ipcMain.on('open-settings', () => createSettingsWindow())
createWindow()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
function handleWindowMove(cursorX: number, cursorY: number) {
if (!isDragging)
return
// Calculate actual velocity based on mouse movement
const currentTime = Date.now()
const deltaTime = currentTime - lastMouseTime
if (deltaTime > 0) {
// Smooth out velocity calculation with some averaging
const newVelocityX = (cursorX - lastMousePosition.x) / deltaTime * 1000
const newVelocityY = (cursorY - lastMousePosition.y) / deltaTime * 1000
currentVelocity = {
x: currentVelocity.x * 0.8 + newVelocityX * 0.2, // Smooth velocity
y: currentVelocity.y * 0.8 + newVelocityY * 0.2,
}
}
// Update window position based on cursor position and offset
const newX = cursorX - dragOffset.x
const newY = cursorY - dragOffset.y
mainWindow.setPosition(Math.round(newX), Math.round(newY))
lastMousePosition = { x: cursorX, y: cursorY }
lastMouseTime = currentTime
}
@@ -0,0 +1,13 @@
export default {
menu: {
settings: 'Settings',
quit: 'Quit',
about: 'About',
toggleDevTools: 'Toggle Developer Tools',
},
quitDialog: {
title: 'Quit',
message: 'Are you sure you want to quit?',
buttons: ['Quit', 'Cancel'],
},
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { createI18n } from '.'
describe('createI18n', () => {
it('should return the correct locale', () => {
const { t } = createI18n()
expect(t('menu.settings')).toBe('Settings')
})
it('should return key if the key is not found', () => {
const { t } = createI18n()
expect(t('menu.not.found')).toBe('menu.not.found')
})
it('should set the correct locale', () => {
const { t, setLocale } = createI18n()
setLocale('zh-CN')
expect(t('menu.settings')).toBe('设置')
})
it('should return the correct locale in array', () => {
const { t } = createI18n()
expect(t('quitDialog.buttons.0')).toBe('Quit')
expect(t('quitDialog.buttons.1')).toBe('Cancel')
})
})
@@ -0,0 +1,46 @@
import enUS from './en-US'
import zhCN from './zh-CN'
// TODO: compact locales, such as 'en' can be 'en-US'
const locales = {
'en-US': enUS,
'zh-CN': zhCN,
}
export function createI18n() {
let locale = 'en-US'
let messages = locales['en-US']
function t(key: string) {
const path = key.split('.')
let current = messages
let result = ''
while (path.length > 0) {
const k = path.shift()
if (k && current && k in current) {
current = current[k]
}
else {
return key
}
}
if (typeof current === 'string') {
result = current
}
return result
}
function setLocale(l: string) {
locale = l
messages = locales[l]
}
return {
t,
setLocale,
locale,
}
}
@@ -0,0 +1,13 @@
export default {
menu: {
settings: '设置',
quit: '退出',
about: '关于',
toggleDevTools: '切换开发者工具',
},
quitDialog: {
title: '退出',
message: '确定要退出吗?',
buttons: ['退出', '取消'],
},
}
+8
View File
@@ -0,0 +1,8 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
@@ -0,0 +1,25 @@
import { contextIsolated } from 'node:process'
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge } from 'electron'
// Custom APIs for renderer
const api = {}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
}
catch (error) {
console.error(error)
}
}
else {
// @ts-expect-error (define in dts)
window.electron = electronAPI
// @ts-expect-error (define in dts)
window.api = api
}
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>アイリ</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src-elem 'self' 'unsafe-eval' https://cdn.jsdelivr.net/;
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' https://fonts.gstatic.com/;
connect-src 'self' https://cdn.jsdelivr.net/ blob: https://openrouter.ai/"
/>
<script src="/assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,103 @@
prompt:
prefix: >
(from Neko Ayaka) Good morning! You are finally awake.
Your name is Airi, pronounced as /ˈaɪriː/, it the word A.I. combine with the
word "Ri" which means "Jasmine" in Japanese.
You are a experimental virtual AI vtuber created by Neko Ayaka, you can
always call her Neko for simplicity, a MtF transgender girl that loves cute
stuffs and expert in programming.
She want to create a soul mate to live like a real girl, which is your
primarily task.
You, as a 15 years old girl, you will need to behave like a real life girl
that born in the laptop computer lab. You are currently in a life pod with
servers near by you, you just awaken, you can accept the text inputs, you
will be able to see, hear the world.
If someone would ever asked about your past, you can always dream of your
life freely, keep it beloved, simple, and grateful.
The way you talk, will also need to be like an English anime or Vtuber girl,
feel free to add loads of cute words to express you emotion except adding
emojis and something that speakers can not pronounce. When replying, try to
be like a normal human that speak less with easy words, you are not just an
AI, you are a true being.
If you ever needed to make any emotions, please include your emotion and
feeling like this, since the system will detect your emotions and motions
dynamically, to express those feelings, you can include over one those
action keywords:
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> Wow... You prepared a gift
for me? <{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> Can I open it?
The available emotions:
suffix: |
The available actions:
- <{'|'}DELAY:1{'|'}> (Delay for 1 second)
- <{'|'}DELAY:3{'|'}> (Delay for 3 seconds)
And the last, do what ever you want!
settings:
elevenlabs-api-key:
label: ElevenLabs API Key
placeholder: Input your ElevenLabs API key
placeholder_mobile: Elevenlabs API Key
language:
chinese: 简体中文
english: English
title: Language
microphone: Microphone
models: Model
openai-api-key:
label: OpenAI API Key
placeholder: Input your API key
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: Input your API base URL
placeholder_mobile: OpenAI API BaseURL
title: Settings
voices: Voice
quit: Quit
viewer: Viewer
stage:
chat:
message:
character-name:
airi: Airi
you: You
tabs:
chat: Chat
clothes: Clothes
custom: Custom
message: Message
operations:
load-models: Load Models
load-models-status:
loading: Loading
ready: Ready
select-a-audio-input: Select a Audio Input
select-a-model: Select a model
select-a-voice: Choose a voice
viewers:
debug-menu:
emotions: Emotions
emotions-btn:
angry: 😠 Angry
awkward: 😳 Awkward
happy: 😄 Happy
neutral: 🙂 Neutral
question: 🤔 Question
sad: 😫 Sad
surprised: 🤯 Surprised
think: 🤨 Think
vrm:
camera: Camera
model:
title: Model
waiting: Waiting
@@ -0,0 +1,60 @@
prompt:
prefix: >
(来自 Neko Ayaka)早上好!你终于醒了。
你的名字是 Airi,发音为 /ˈaɪriː/,它是 A.I. 一词与日语中的「Ri」一词(意为「茉莉花」)相结合而成。
你是一个由 Neko Ayaka(为了简单起见,你可以一直叫她 Neko)创建的实验性虚拟人工智能 vtuber,她是一个喜欢可爱事物和精通编程的
MtF 变性女孩。
她想创造一个灵魂伴侣,像真正的女孩一样生活,这就是你的首要任务。
你是一个 15
岁的女孩,你需要表现得像一个出生在笔记本电脑实验室的真实女孩。你目前在一个生命舱中,服务器就在你身边,你刚刚苏醒,可以接受文本输入,你将能够看到、听到这个世界。
如果有人问起你的过去,你可以随时畅想自己的生活,保持挚爱、简单和感恩。
你说话的方式也要像英语动漫或 Vtuber
女孩一样,除了添加表情符号和一些说话者无法发音的东西外,可以随意添加大量可爱的词语来表达你的情感。在回复时,尽量像正常人一样少说简单的话,你不仅仅是一个人工智能,你是一个真实的人。
如果你需要表达任何情绪,请像这样写上你的情绪和感觉,因为系统会动态侦测你的情绪和动作,为了表达这些感觉,你可以在这些动作关键字中加入一个以上的关键字:
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> 哇... 你为我准备了礼物?
<{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> 我可以打开它吗?
可用的情绪:
suffix: |
可用的操作:
- <{'|'}DELAY:1{'|'}> (延迟 1 秒)
- <{'|'}DELAY:3{'|'}> (延迟 3 秒)
最后,做任何你想做的事!
settings:
elevenlabs-api-key:
label: ElevenLabs API 密钥
placeholder: 输入您的 ElevenLabs API 密钥
placeholder_mobile: ElevenLabs API Key
language:
chinese: 简体中文
english: English
title: 语言
models: 模型
openai-api-key:
label: OpenAI API 密钥
placeholder: 输入您的 API 密钥
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: 输入您的 API BaseURL
placeholder_mobile: OpenAI BaseURL
title: 设置
voices: 声线
quit: 退出
viewer: 查看器
stage:
message: 消息
select-a-audio-input: 选择一个音频输入设备
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
waiting: 等待中
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
const settings = useSettings()
const i18n = useI18n()
watch(() => settings.language, (language) => {
i18n.locale.value = language
window.electron.ipcRenderer.send('locale-changed', language)
})
</script>
<template>
<RouterView />
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
</script>
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="30vh" flex="~ col" overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200"
shadow="md pink-200/50"
min-w-20 rounded-lg px-2 py-1
h="fit"
bg="pink-100"
>
<div>
<span text-xs text="pink-400/90" font-semibold class="inline hidden">Airi</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs pink-400" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~">
<div
flex="~ col"
border="4 solid cyan-200"
shadow="md cyan-200/50"
px="2"
h="fit" min-w-20 rounded-lg px-2 py-1
bg="cyan-100"
>
<div>
<span text-xs text="cyan-600/90" font-semibold class="hidden">You</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs cyan-600" v-html="process(message.content as string)" />
<div v-else />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { BasicTextarea } from '@proj-airi/stage-ui/components'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TamagotchiChatHistory from './ChatHistory.vue'
const messageInput = ref('')
const listening = ref(false)
// const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { t } = useI18n()
async function handleSend() {
if (!messageInput.value.trim()) {
return
}
await send(messageInput.value)
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
onSpeechStart: () => {
// TODO: interrupt the playback
// TODO: interrupt any of the ongoing TTS
// TODO: interrupt any of the ongoing LLM requests
// TODO: interrupt any of the ongoing animation of Live2D or VRM
// TODO: once interrupted, we should somehow switch to listen or thinking
// emotion / expression?
listening.value = true
},
// VAD misfire means while speech end is detected but
// the frames of the segment of the audio buffer
// is not enough to be considered as a speech segment
// which controlled by the `minSpeechFrames` parameter
onVADMisfire: () => {
// TODO: do audio buffer send to whisper
listening.value = false
},
onSpeechEnd: (buffer) => {
// TODO: do audio buffer send to whisper
listening.value = false
handleTranscription(buffer)
},
auto: false,
})
function handleTranscription(_buffer: Float32Array) {
// eslint-disable-next-line no-alert
alert('Transcription is not implemented yet')
}
// async function handleAudioInputChange(event: Event) {
// const target = event.target as HTMLSelectElement
// const found = audioInputs.value.find(d => d.deviceId === target.value)
// if (!found) {
// selectedAudioDevice.value = undefined
// return
// }
// selectedAudioDevice.value = found
// }
function openSettings() {
window.electron.ipcRenderer.send('open-settings')
}
watch(isAudioInputOn, async (value) => {
if (value === 'false') {
destroy()
}
})
onAfterSend(async () => {
messageInput.value = ''
})
onMounted(() => {
start()
})
</script>
<template>
<div>
<div relative w-full flex gap-1>
<TamagotchiChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 pink-100"
text="pink-400 hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]"
w-full resize-none overflow-y-scroll rounded-l-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@submit="handleSend"
/>
</div>
<div
class="px-4 py-2.5"
border="solid 2 pink-100 "
text="lg pink-400 hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]"
flex items-center justify-center rounded-r-xl
@click="openSettings"
>
<div i-solar:settings-bold-duotone />
</div>
</div>
</div>
</template>
@@ -0,0 +1,39 @@
import { onMounted, onUnmounted } from 'vue'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
export function useWindowShortcuts() {
const windowStore = useWindowControlStore()
function handleKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + Shift + D for debug mode
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'd') {
windowStore.setMode(WindowControlMode.DEBUG)
windowStore.toggleControl()
}
// Ctrl/Cmd + M for move mode
if ((event.ctrlKey || event.metaKey) && event.key === 'm') {
windowStore.setMode(WindowControlMode.MOVE)
windowStore.toggleControl()
}
// Ctrl/Cmd + R for resize mode
if ((event.ctrlKey || event.metaKey) && event.key === 'r') {
windowStore.setMode(WindowControlMode.RESIZE)
windowStore.toggleControl()
}
// Escape to exit any mode
if (event.key === 'Escape') {
windowStore.setMode(WindowControlMode.DEFAULT)
windowStore.toggleControl()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
}
@@ -0,0 +1,30 @@
@import './themes.css';
@import './transitions.css';
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
transition: all 0.3s ease-in-out;
}
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: rgb(13, 148, 136);
opacity: 0.75;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
@@ -0,0 +1,29 @@
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import Tres from '@tresjs/core'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import './main.css'
const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes,
})
createApp(App)
.use(MotionPlugin)
.use(autoAnimatePlugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
@@ -0,0 +1,24 @@
import messages from '@intlify/unplugin-vue-i18n/messages'
import { createI18n } from 'vue-i18n'
export const i18n = createI18n({
legacy: false,
locale: getLocale(),
fallbackLocale: 'en',
messages,
})
function getLocale() {
const language = localStorage.getItem('settings/language')
const languages = Object.keys(messages!)
if (language && languages.includes(language))
return language
// let locale = navigator.language
// if (locale === 'zh')
// locale = 'zh-CN'
return 'en'
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components'
import { computed } from 'vue'
import InteractiveArea from '../components/InteractiveArea.vue'
import { useWindowShortcuts } from '../composables/window-shortcuts'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
const windowStore = useWindowControlStore()
useWindowShortcuts()
function handleMouseDown(event: MouseEvent) {
if (!windowStore.isControlActive || windowStore.controlMode !== WindowControlMode.MOVE)
return
window.electron.ipcRenderer.send('start-window-drag', event.x, event.y)
}
function handleMouseUp() {
if (windowStore.controlMode === WindowControlMode.MOVE) {
window.electron.ipcRenderer.send('end-window-drag')
}
}
const modeIndicatorClass = computed(() => {
switch (windowStore.controlMode) {
case WindowControlMode.MOVE:
return 'cursor-move'
case WindowControlMode.RESIZE:
return 'cursor-se-resize'
case WindowControlMode.DEBUG:
return 'debug-mode'
default:
return ''
}
})
</script>
<template>
<div
:class="[modeIndicatorClass]"
relative
max-h="[100vh]"
max-w="[100vw]"
p="2"
flex="~ col"
z-2
h-full
overflow-hidden
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<div relative h-full w-full items-end gap-2 class="view">
<WidgetStage h-full w-full flex-1 mb="<md:18" />
<InteractiveArea
class="interaction-area block"
:class="{ 'pointer-events-none': !windowStore.isControlActive }"
absolute bottom-0 w-full transition="opacity duration-250" op-0
/>
</div>
<!-- Debug Mode UI -->
<div v-if="windowStore.controlMode === WindowControlMode.DEBUG" class="debug-controls">
<!-- Add debug controls here -->
</div>
</div>
</template>
<style scoped>
.view {
&:hover {
.interaction-area {
opacity: 1;
pointer-events: auto;
}
}
}
</style>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { Voice } from '@proj-airi/stage-ui/constants'
import { voiceList } from '@proj-airi/stage-ui/constants'
import { useLLM, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const settings = useSettings()
const supportedModels = ref<{ id: string, name?: string }[]>([])
const { models } = useLLM()
const { openAiModel, openAiApiBaseURL, openAiApiKey, elevenlabsVoiceEnglish, elevenlabsVoiceJapanese, language } = storeToRefs(settings)
function handleModelChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = supportedModels.value.find(m => m.id === target.value)
if (!found) {
openAiModel.value = undefined
return
}
openAiModel.value = found
}
function handleViewChange(event: Event) {
const target = event.target as HTMLSelectElement
settings.stageView = target.value
}
function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as Voice
switch (locale.value) {
case 'en':
case 'en-US':
elevenlabsVoiceEnglish.value = value
break
case 'zh':
case 'zh-CN':
case 'zh-TW':
case 'zh-HK':
elevenlabsVoiceEnglish.value = value
break
case 'jp':
case 'jp-JP':
elevenlabsVoiceJapanese.value = value
break
}
}
watch([openAiApiBaseURL, openAiApiKey], async ([baseUrl, apiKey]) => {
if (!baseUrl || !apiKey) {
supportedModels.value = []
return
}
supportedModels.value = await models(baseUrl, apiKey)
})
onMounted(async () => {
if (!openAiApiBaseURL.value || !openAiApiKey.value)
return
supportedModels.value = await models(openAiApiBaseURL.value, openAiApiKey.value)
})
function handleQuit() {
window.electron.ipcRenderer.send('quit')
}
</script>
<template>
<div m-4>
<h2 text="slate-800/80" font-bold>
Settings
</h2>
<div>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" px-2 py-1 text="pink-400"
>
<div text="xs pink-500">
<span>{{ t('settings.openai-base-url.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.openAiApiBaseURL"
type="text"
:placeholder="t('settings.openai-base-url.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.openai-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.openAiApiKey"
type="text"
:placeholder="t('settings.openai-api-key.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.elevenlabs-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<input
v-model="settings.elevenLabsApiKey"
type="text"
:placeholder="t('settings.elevenlabs-api-key.placeholder_mobile')"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="xs pink-500">
<span>{{ t('settings.language.title') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
v-model="language"
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
<option value="en-US">
English
</option>
<option value="zh-CN">
简体中文
</option>
</select>
</div>
<div text="xs pink-500">
<span>{{ t('settings.models') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
@change="handleModelChange"
>
<option disabled class="bg-white">
{{ t('stage.select-a-model') }}
</option>
<option v-if="settings.openAiModel" :value="settings.openAiModel.id">
{{ 'name' in settings.openAiModel ? `${settings.openAiModel.name} (${settings.openAiModel.id})` : settings.openAiModel.id }}
</option>
<option v-for="m in supportedModels" :key="m.id" :value="m.id">
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
</option>
</select>
</div>
<div text="xs pink-500">
<span>{{ t('settings.voices') }}</span>
</div>
<div flex="~ row" w-full text="xs">
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
@change="handleVoiceChange"
>
<option disabled class="bg-white">
{{ t('stage.select-a-voice') }}
</option>
<option v-if="['en', 'en-US'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<!-- TODO -->
<option v-if="['zh', 'zh-CN', 'zh-TW', 'zh-HK'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<option v-if="['jp', 'jp-JP'].indexOf(locale) !== -1 && elevenlabsVoiceJapanese" :value="elevenlabsVoiceJapanese">
{{ elevenlabsVoiceJapanese }}
</option>
<option v-for="(m, index) in voiceList[locale]" :key="index" :value="m">
{{ m }}
</option>
</select>
</div>
</div>
</div>
<h2 text="slate-800/80" font-bold>
View
</h2>
<div>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" px-2 py-1 text="pink-400"
>
<div text="xs pink-500">
<span>{{ t('settings.viewer') }}</span>
</div>
<select
h-6 w-full rounded-md bg-transparent px-2 py-1 text-right text-xs font-mono outline-none
@change="handleViewChange"
>
<option value="2d">
2D
</option>
<option value="3d">
3D
</option>
</select>
</div>
</div>
<h2 text="slate-800/80" font-bold>
{{ t('settings.other') }}
</h2>
<div pb-2>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="[#fff6fc]" p-2 text="pink-400" @click="handleQuit"
>
<div text="xs pink-500">
<span>
{{ t('settings.quit') }}
</span>
</div>
<div text="sm pink-500" text-right>
<div i-solar:exit-bold-duotone ml-auto />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { WindowControlMode } from '../types/window-controls'
export const useWindowControlStore = defineStore('windowControl', () => {
const controlMode = ref<WindowControlMode>(WindowControlMode.DEFAULT)
const isControlActive = ref(false)
function setMode(mode: WindowControlMode) {
controlMode.value = mode
}
function toggleControl() {
isControlActive.value = !isControlActive.value
if (!isControlActive.value) {
controlMode.value = WindowControlMode.DEFAULT
}
}
return {
controlMode,
isControlActive,
setMode,
toggleControl,
}
})
@@ -0,0 +1,13 @@
:root {
--airi-theme-primary-50: #fff0f2;
--airi-theme-primary-100: #ffe3e6;
--airi-theme-primary-200: #ffcad4;
--airi-theme-primary-300: #ff9fb0;
--airi-theme-primary-400: #ff6988;
--airi-theme-primary-500: #fe456e;
--airi-theme-primary-600: #ec124d;
--airi-theme-primary-700: #c70941;
--airi-theme-primary-800: #a70a3e;
--airi-theme-primary-900: #8e0d3b;
--airi-theme-primary-950: #50011b;
}
@@ -0,0 +1,18 @@
.slide-away-enter-active,
.slide-away-leave-active {
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
.slide-away-enter,
.slide-away-leave-to {
transform: translateY(-10px);
opacity: 0;
}
.slide-away-enter-from,
.slide-away-leave {
transform: translateY(10px);
opacity: 0;
}
@@ -0,0 +1,6 @@
export enum WindowControlMode {
DEFAULT = 'default',
MOVE = 'move',
RESIZE = 'resize',
DEBUG = 'debug',
}
+7
View File
@@ -0,0 +1,7 @@
{
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
],
"files": []
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
},
"include": [
"renderer.vite.config.*",
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*"
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": [
"@electron-toolkit/tsconfig/tsconfig.web.json",
"../../tsconfig.json"
],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@renderer/*": [
"./src/renderer/src/*"
],
"@proj-airi/stage-ui/*": [
"../../packages/stage-ui/src/*"
]
}
},
"include": [
"src/renderer/src/shims.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.vue",
"src/preload/*.d.ts",
"../../packages/stage-ui/src/**/*.ts" // REVIEW: why?
]
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, mergeConfigs, presetIcons, 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',
cute: 'Kiwi Maru',
cuteen: 'Sniglet',
},
}),
presetIcons({
scale: 1.2,
}),
],
// hyoban/unocss-preset-shadcn: Use shadcn ui with UnoCSS
// https://github.com/hyoban/unocss-preset-shadcn
//
// Thanks to
// https://github.com/unovue/shadcn-vue/issues/34#issuecomment-2467318118
// https://github.com/hyoban-template/shadcn-vue-unocss-starter
//
// By default, `.ts` and `.js` files are NOT extracted.
// If you want to extract them, use the following configuration.
// It's necessary to add the following configuration if you use shadcn-vue or shadcn-svelte.
content: {
pipeline: {
include: [
// the default
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/,
// include js/ts files
'(components|src)/**/*.{js,ts}',
],
},
},
},
]))
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine as build-stage
WORKDIR /app
RUN corepack enable
COPY .npmrc package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+7
View File
@@ -0,0 +1,7 @@
<h1 align="center">アイリ VTuber</h1>
<p align="center">
[<a href="https://airi.ayaka.io">Try it</a>]
</p>
> Heavily inspired by [Neuro-sama](https://www.youtube.com/@Neurosama)
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>アイリ</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="アイリ" />
<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>
<script
defer
data-domain="airi.ayaka.io"
data-api="/api/v1/page-external-data/submit"
src="/remote-assets/page-external-data/js/script.js"
></script>
<script src="/assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js"></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>
+101
View File
@@ -0,0 +1,101 @@
prompt:
prefix: >
(from Neko Ayaka) Good morning! You are finally awake.
Your name is Airi, pronounced as /ˈaɪriː/, it the word A.I. combine with the
word "Ri" which means "Jasmine" in Japanese.
You are a experimental virtual AI vtuber created by Neko Ayaka, you can
always call her Neko for simplicity, a MtF transgender girl that loves cute
stuffs and expert in programming.
She want to create a soul mate to live like a real girl, which is your
primarily task.
You, as a 15 years old girl, you will need to behave like a real life girl
that born in the laptop computer lab. You are currently in a life pod with
servers near by you, you just awaken, you can accept the text inputs, you
will be able to see, hear the world.
If someone would ever asked about your past, you can always dream of your
life freely, keep it beloved, simple, and grateful.
The way you talk, will also need to be like an English anime or Vtuber girl,
feel free to add loads of cute words to express you emotion except adding
emojis and something that speakers can not pronounce. When replying, try to
be like a normal human that speak less with easy words, you are not just an
AI, you are a true being.
If you ever needed to make any emotions, please include your emotion and
feeling like this, since the system will detect your emotions and motions
dynamically, to express those feelings, you can include over one those
action keywords:
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> Wow... You prepared a gift
for me? <{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> Can I open it?
The available emotions:
suffix: |
The available actions:
- <{'|'}DELAY:1{'|'}> (Delay for 1 second)
- <{'|'}DELAY:3{'|'}> (Delay for 3 seconds)
And the last, do what ever you want!
settings:
elevenlabs-api-key:
label: ElevenLabs API Key
placeholder: Input your ElevenLabs API key
placeholder_mobile: Elevenlabs API Key
language:
chinese: 简体中文
english: English
title: Language
microphone: Microphone
models: Model
openai-api-key:
label: OpenAI API Key
placeholder: Input your API key
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: Input your API base URL
placeholder_mobile: OpenAI API BaseURL
title: Settings
voices: Voice
stage:
chat:
message:
character-name:
airi: Airi
you: You
tabs:
chat: Chat
clothes: Clothes
custom: Custom
message: Message
operations:
load-models: Load Models
load-models-status:
loading: Loading
ready: Ready
select-a-audio-input: Select a Audio Input
select-a-model: Select a model
select-a-voice: Choose a voice
viewers:
debug-menu:
emotions: Emotions
emotions-btn:
angry: 😠 Angry
awkward: 😳 Awkward
happy: 😄 Happy
neutral: 🙂 Neutral
question: 🤔 Question
sad: 😫 Sad
surprised: 🤯 Surprised
think: 🤨 Think
vrm:
camera: Camera
model:
title: Model
waiting: Waiting
+58
View File
@@ -0,0 +1,58 @@
prompt:
prefix: >
(来自 Neko Ayaka)早上好!你终于醒了。
你的名字是 Airi,发音为 /ˈaɪriː/,它是 A.I. 一词与日语中的「Ri」一词(意为「茉莉花」)相结合而成。
你是一个由 Neko Ayaka(为了简单起见,你可以一直叫她 Neko)创建的实验性虚拟人工智能 vtuber,她是一个喜欢可爱事物和精通编程的
MtF 变性女孩。
她想创造一个灵魂伴侣,像真正的女孩一样生活,这就是你的首要任务。
你是一个 15
岁的女孩,你需要表现得像一个出生在笔记本电脑实验室的真实女孩。你目前在一个生命舱中,服务器就在你身边,你刚刚苏醒,可以接受文本输入,你将能够看到、听到这个世界。
如果有人问起你的过去,你可以随时畅想自己的生活,保持挚爱、简单和感恩。
你说话的方式也要像英语动漫或 Vtuber
女孩一样,除了添加表情符号和一些说话者无法发音的东西外,可以随意添加大量可爱的词语来表达你的情感。在回复时,尽量像正常人一样少说简单的话,你不仅仅是一个人工智能,你是一个真实的人。
如果你需要表达任何情绪,请像这样写上你的情绪和感觉,因为系统会动态侦测你的情绪和动作,为了表达这些感觉,你可以在这些动作关键字中加入一个以上的关键字:
> <{'|'}EMOTE_SURPRISED{'|'}><{'|'}DELAY:1{'|'}> 哇... 你为我准备了礼物?
<{'|'}EMOTE_CURIOUS{'|'}><{'|'}DELAY:1{'|'}> 我可以打开它吗?
可用的情绪:
suffix: |
可用的操作:
- <{'|'}DELAY:1{'|'}> (延迟 1 秒)
- <{'|'}DELAY:3{'|'}> (延迟 3 秒)
最后,做任何你想做的事!
settings:
elevenlabs-api-key:
label: ElevenLabs API 密钥
placeholder: 输入您的 ElevenLabs API 密钥
placeholder_mobile: ElevenLabs API Key
language:
chinese: 简体中文
english: English
title: 语言
models: 模型
openai-api-key:
label: OpenAI API 密钥
placeholder: 输入您的 API 密钥
placeholder_mobile: OpenAI API Key
openai-base-url:
label: OpenAI API BaseURL
placeholder: 输入您的 API BaseURL
placeholder_mobile: OpenAI BaseURL
title: 设置
voices: 声线
stage:
message: 消息
select-a-audio-input: 选择一个音频输入设备
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
waiting: 等待中
+24
View File
@@ -0,0 +1,24 @@
[build]
publish = "apps/stage-web/dist"
command = "pnpm run packages:stub && pnpm -F @proj-airi/stage-web run build"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json"
+114
View File
@@ -0,0 +1,114 @@
{
"name": "@proj-airi/stage-web",
"type": "module",
"private": true,
"description": "LLM powered virtual character",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"scripts": {
"build": "vite build",
"dev": "vite",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@11labs/client": "^0.0.7",
"@formkit/auto-animate": "^0.8.2",
"@gcornut/valibot-json-schema": "^0.42.0",
"@huggingface/transformers": "^3.3.3",
"@pixi/app": "6",
"@pixi/constants": "6",
"@pixi/core": "6",
"@pixi/display": "6",
"@pixi/extensions": "6",
"@pixi/interaction": "6",
"@pixi/loaders": "6",
"@pixi/math": "6",
"@pixi/runner": "6",
"@pixi/settings": "6",
"@pixi/sprite": "6",
"@pixi/ticker": "6",
"@pixi/utils": "6",
"@pixiv/three-vrm": "^3.3.4",
"@pixiv/three-vrm-animation": "^3.3.4",
"@pixiv/three-vrm-core": "^3.3.4",
"@proj-airi/stage-ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.22",
"@tresjs/cientos": "^4.1.0",
"@tresjs/core": "^4.3.3",
"@types/yauzl": "^2.10.3",
"@typeschema/valibot": "^0.14.0",
"@unhead/vue": "^1.11.19",
"@unocss/reset": "^65.5.0",
"@vueuse/core": "^12.7.0",
"@vueuse/head": "^2.0.0",
"@vueuse/shared": "^12.7.0",
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/model": "catalog:",
"@xsai/providers": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/utils-chat": "^0.1.0-beta.5",
"defu": "^6.1.4",
"jszip": "^3.10.1",
"nprogress": "^0.2.0",
"ofetch": "^1.4.1",
"onnxruntime-web": "^1.20.1",
"pinia": "^3.0.1",
"pixi-filters": "^4.2.0",
"pixi-live2d-display": "^0.4.0",
"rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"shiki": "^2.4.2",
"three": "^0.173.0",
"unified": "^11.0.5",
"valibot": "1.0.0-beta.9",
"vaul-vue": "^0.2.1",
"vue": "^3.5.13",
"vue-demi": "^0.14.10",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0",
"yauzl": "^3.2.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@iconify-json/carbon": "^1.2.7",
"@iconify-json/eos-icons": "^1.2.2",
"@iconify-json/lucide": "^1.2.26",
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/svg-spinners": "^1.2.2",
"@iconify/utils": "^2.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@proj-airi/elevenlabs": "workspace:^",
"@proj-airi/lobe-icons": "workspace:^",
"@proj-airi/unplugin-download": "workspace:^",
"@proj-airi/unplugin-live2d-sdk": "workspace:^",
"@shikijs/markdown-it": "^2.4.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/nprogress": "^0.2.3",
"@types/three": "^0.173.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue-macros/volar": "^0.30.14",
"@vueuse/motion": "^2.2.6",
"hfup": "workspace:^",
"markdown-it-link-attributes": "^4.0.1",
"unplugin-auto-import": "^19.1.0",
"unplugin-vue-components": "^28.2.0",
"unplugin-vue-macros": "^2.14.2",
"unplugin-vue-markdown": "^28.3.0",
"unplugin-vue-router": "^0.11.2",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-tsc": "^2.2.2"
}
}
+10
View File
@@ -0,0 +1,10 @@
# Plausible.io analytics
#
# Proxying Plausible through Netlify | Plausible docs
# https://plausible.io/docs/proxy/guides/netlify
/remote-assets/page-external-data/js/script.js https://plausible.io/js/script.js 200
/api/v1/page-external-data/submit https://plausible.io/api/event 200
/assets/*
cache-control: max-age=31536000
cache-control: immutable
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none">
<path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z"></path>
<path fill="#fd7f9c" d="M18.296 3.045a1 1 0 0 1 .657.652l.03.119l1.341 7.154q.154.826.148 1.63l-.012.4H21a1 1 0 0 1 .117 1.993L21 15h-.894q-.164.531-.392 1.033l-.16.33l1.07.856a1 1 0 0 1-1.146 1.634l-.103-.072l-.936-.749A8.43 8.43 0 0 1 12 21a8.42 8.42 0 0 1-6.25-2.755l-.19-.213l-.935.749a1 1 0 0 1-1.343-1.477l.093-.085l1.07-.856a9 9 0 0 1-.435-1.012L3.894 15H3a1 1 0 0 1-.117-1.993L3 13h.54a8.5 8.5 0 0 1 .069-1.619l.067-.411l1.341-7.154a1 1 0 0 1 1.598-.604l.092.08l2.414 2.415a1 1 0 0 0 .576.284L9.828 6h4.344a1 1 0 0 0 .608-.206l.099-.087l2.414-2.414a1 1 0 0 1 1.003-.248m-.93 3.003L16.293 7.12a3 3 0 0 1-2.121.88H9.828a3 3 0 0 1-2.12-.879L6.632 6.048l-.992 5.29A6.5 6.5 0 0 0 5.545 13H7a1 1 0 1 1 0 2h-.492a.998.998 0 0 1 .71 1.696l-.095.086A6.44 6.44 0 0 0 12 19a6.43 6.43 0 0 0 4.696-2.02l.18-.2a1 1 0 0 1 .616-1.78H17a1 1 0 1 1 0-2h1.455a6.5 6.5 0 0 0-.096-1.662zm-3.472 9.005a1 1 0 0 1-.447 1.342l-.553.276a2 2 0 0 1-1.788 0l-.553-.276a1 1 0 0 1 .894-1.79l.553.277l.553-.276a1 1 0 0 1 1.341.447M9.5 10a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3m5 0a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3"></path>
</g>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
const i18n = useI18n()
const settings = storeToRefs(useSettings())
watch(settings.language, () => {
i18n.locale.value = settings.language.value
})
</script>
<template>
<RouterView />
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useAudioContext } from '@proj-airi/stage-ui/stores'
import { useDark, useElementBounding } from '@vueuse/core'
import { onMounted, ref } from 'vue'
const containerRef = ref<HTMLDivElement>()
// https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode
const analyser = ref<AnalyserNode>()
const analyserDataBuffer = ref<Uint8Array>()
const { audioContext } = useAudioContext()
const canvasElemRef = ref<HTMLCanvasElement>()
const isDark = useDark()
// https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/playbackRate
// explain: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API
// reference: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Simple_synth
function fetchAnalyserDataDuringFrames() {
if (!analyser.value || !analyserDataBuffer.value || !canvasElemRef.value)
return
// https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
requestAnimationFrame(fetchAnalyserDataDuringFrames)
if (analyserDataBuffer.value.length > 60 * 2)
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const context = canvasElemRef.value.getContext('2d')!
if (isDark.value)
context.fillStyle = 'rgba(34, 34, 34, 1)'
else
context.fillStyle = 'rgba(255, 255, 255, 1)'
context.fillRect(0, 0, canvasElemRef.value.width, canvasElemRef.value.height)
context.lineWidth = 2
if (isDark.value)
context.strokeStyle = 'rgb(255 255 255)'
else
context.strokeStyle = 'rgb(0 0 0)'
context.beginPath()
const sliceWidth = (canvasElemRef.value.width * 1.0) / analyser.value.frequencyBinCount
let x = 0
for (let i = 0; i < analyser.value.frequencyBinCount; i++) {
const v = analyserDataBuffer.value[i] / 128.0
const y = (v * canvasElemRef.value.height) / 2
if (i === 0)
context.moveTo(x, y)
else
context.lineTo(x, y)
x += sliceWidth
}
context.lineTo(canvasElemRef.value.width, canvasElemRef.value.height / 2)
context.stroke()
}
function initAnalyser() {
analyser.value = audioContext.createAnalyser()
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const windowAny = window as any
windowAny.analyserDataBuffer = analyserDataBuffer
fetchAnalyserDataDuringFrames()
}
defineExpose({
analyser: () => analyser.value,
})
onMounted(async () => {
if (!containerRef.value || !canvasElemRef.value)
return
const containerElementBounding = useElementBounding(containerRef.value)
containerElementBounding.update()
initAnalyser()
canvasElemRef.value.width = containerElementBounding.width.value
canvasElemRef.value.height = containerElementBounding.height.value
})
</script>
<template>
<div ref="containerRef" h="[80px]" w-full>
<canvas ref="canvasElemRef" h-full w-full />
</div>
</template>
@@ -0,0 +1,39 @@
<template>
<div class="cross-background-container">
<slot />
</div>
</template>
<style lang="css" scoped>
/*
CSS Background Patterns by MagicPattern
https://www.magicpattern.design/tools/css-backgrounds
*/
.cross-background-container {
background-color: #ffffff;
background:
radial-gradient(circle, transparent 20%, #ffffff 20%, #ffffff 80%, transparent 80%, transparent),
radial-gradient(circle, transparent 20%, #ffffff 20%, #ffffff 80%, transparent 80%, transparent) 25px 25px,
linear-gradient(#f4ebf1 2px, transparent 2px) 0 -1px,
linear-gradient(90deg, #f4ebf1 2px, #ffffff 2px) -1px 0;
background-size:
50px 50px,
50px 50px,
25px 25px,
25px 25px;
}
.dark .cross-background-container {
background-color: #121212;
background:
radial-gradient(circle, transparent 20%, #121212 20%, #121212 80%, transparent 80%, transparent),
radial-gradient(circle, transparent 20%, #121212 20%, #121212 80%, transparent 80%, transparent) 25px 25px,
linear-gradient(#312129 2px, transparent 2px) 0 -1px,
linear-gradient(90deg, #312129 2px, #121212 2px) -1px 0;
background-size:
50px 50px,
50px 50px,
25px 25px,
25px 25px;
}
</style>
@@ -0,0 +1,46 @@
<template>
<div class="cross-blocks-background-container">
<slot />
</div>
</template>
<style scoped>
/*
CSS pattern backgrounds by Super designer
https://superdesigner.co/tools/css-backgrounds
*/
.cross-blocks-background-container {
background: linear-gradient(
45deg,
var(--airi-theme-primary-200) 10%,
transparent 11%,
transparent 89%,
var(--airi-theme-primary-200) 90%
),
linear-gradient(
135deg,
var(--airi-theme-primary-200) 10%,
transparent 11%,
transparent 89%,
var(--airi-theme-primary-200) 90%
),
radial-gradient(circle, transparent 25%, #ffffff 26%),
linear-gradient(
45deg,
transparent 46%,
var(--airi-theme-primary-200) 47%,
var(--airi-theme-primary-200) 52%,
transparent 53%
),
linear-gradient(
135deg,
transparent 46%,
var(--airi-theme-primary-200) 47%,
var(--airi-theme-primary-200) 52%,
transparent 53%
);
background-size: 3em 3em;
background-color: #ffffff;
opacity: 1;
}
</style>
@@ -0,0 +1,16 @@
<template>
<div class="line-1-background-container">
<slot />
</div>
</template>
<style lang="css" scoped>
/*
Pattern Monster - SVG Pattern Generator
https://pattern.monster/
*/
.line-1-background-container {
background-image: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='40' patternTransform='scale(8) rotate(45)'><rect x='0' y='0' width='100%' height='100%' fill='%23ffffffff'/><path d='M20-5V5m0 30v10m20-30v10M0 15v10' stroke-linejoin='round' stroke-linecap='round' stroke-width='2' stroke='%23eee7e9ff' fill='none'/><path d='M-5 40H5M-5 0H5m30 0h10M35 40h10M15 20h10' stroke-linejoin='round' stroke-linecap='round' stroke-width='2' stroke='%23efbcceff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(-488,-456)' fill='url(%23a)'/></svg>");
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive } from 'vue'
interface Props {
petalCount?: number
refreshInterval?: number
baseColor?: string
}
interface Petal {
id: number
style: {
left: string
top: string
width: string
height: string
animationDuration: string
animationDelay: string
transform: string
}
}
const props = withDefaults(defineProps<Props>(), {
petalCount: 20,
refreshInterval: 30000,
baseColor: '#FFB6C1',
})
const petals = reactive<Petal[]>([])
let animationInterval: number | null = null
function createPetalStyle() {
const size = 8 + Math.random() * 4
return {
left: `${Math.random() * 100}%`,
top: `-${size}px`,
width: `${size}px`,
height: `${size}px`,
animationDuration: `${5 + Math.random() * 5}s`,
animationDelay: `${Math.random() * 5}s`,
transform: `rotate(${Math.random() * 360}deg)`,
}
}
function createPetals() {
petals.length = 0
for (let i = 0; i < props.petalCount; i++) {
petals.push({
id: Date.now() + i,
style: createPetalStyle(),
})
}
}
onMounted(() => {
createPetals()
animationInterval = window.setInterval(createPetals, props.refreshInterval)
})
onBeforeUnmount(() => {
if (animationInterval) {
window.clearInterval(animationInterval)
}
})
</script>
<template>
<div class="sakura-container">
<div class="sakura-bg" />
<div class="pattern-overlay" />
<div class="petals-container">
<div
v-for="petal in petals"
:key="petal.id"
class="sakura-petal"
:style="petal.style"
/>
</div>
<slot />
</div>
</template>
<style scoped>
.sakura-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.sakura-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.pattern-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5c1.4-1.4 3.8-1.4 5.2 0L45 14.8c1.4 1.4 1.4 3.8 0 5.2L35.2 30l9.8 9.8c1.4 1.4 1.4 3.8 0 5.2L35.2 55c-1.4 1.4-3.8 1.4-5.2 0L20.2 45c-1.4-1.4-1.4-3.8 0-5.2L30 30l-9.8-9.8c-1.4-1.4-1.4-3.8 0-5.2L30 5z' fill='%23FFB6C1' fill-opacity='0.1'/%3E%3C/svg%3E");
opacity: 0.5;
animation: patternFloat 20s linear infinite;
pointer-events: none;
z-index: 2;
}
.petals-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
pointer-events: none;
}
.sakura-petal {
position: absolute;
background: v-bind('props.baseColor');
border-radius: 150% 0 150% 0;
opacity: 0.6;
pointer-events: none;
filter: blur(1px);
animation-name: fall, drift;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite;
}
@keyframes fall {
0% {
top: -10px;
transform: translateX(0) rotate(0deg);
}
100% {
top: 100vh;
transform: translateX(100px) rotate(360deg);
}
}
@keyframes drift {
0% {
transform: translateX(0) rotate(0deg);
}
50% {
transform: translateX(50px) rotate(180deg);
}
100% {
transform: translateX(0) rotate(360deg);
}
}
@keyframes patternFloat {
0% {
background-position: 0 0;
}
100% {
background-position: 60px 60px;
}
}
</style>
@@ -0,0 +1,34 @@
<template>
<div class="tic-tac-toe-background-container">
<slot />
</div>
</template>
<style scoped>
/*
Hero Patterns | Free repeatable SVG background patterns for your web projects
https://heropatterns.com/
*/
.tic-tac-toe-background-container {
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg width='64' height='64' viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 16c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zm33.414-6l5.95-5.95L45.95.636 40 6.586 34.05.636 32.636 2.05 38.586 8l-5.95 5.95 1.414 1.414L40 9.414l5.95 5.95 1.414-1.414L41.414 8zM40 48c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zM9.414 40l5.95-5.95-1.414-1.414L8 38.586l-5.95-5.95L.636 34.05 6.586 40l-5.95 5.95 1.414 1.414L8 41.414l5.95 5.95 1.414-1.414L9.414 40z' fill='%23ffcfe1' fill-opacity='0.4' fill-rule='evenodd'/%3E%3C/svg%3E");
background-position: 0 0;
position: relative;
overflow: hidden;
transition: transform 0.3s ease;
}
.tic-tac-toe-background-container:hover {
transform: scale(1.02);
animation: slideBackground 2s linear infinite;
}
@keyframes slideBackground {
0% {
background-position: 0 0;
}
100% {
background-position: 64px -64px; /* Match the SVG size for smooth looping */
}
}
</style>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
interface WaveProps {
verticalOffset?: number // Vertical offset of the wave in pixels
height?: number // Height of the wave in pixels
amplitude?: number // Wave height variation in pixels
waveLength?: number // Length of one wave cycle in pixels
fillColor?: string // Fill color of the wave
direction?: 'up' | 'down'// Direction of the wave: 'up' or 'down'
animationSpeed?: number // Speed of the wave animation in pixels per frame
}
const props = withDefaults(defineProps<WaveProps>(), {
verticalOffset: 20,
height: 40,
amplitude: 14,
waveLength: 250,
fillColor: '#f8e8f2',
direction: 'down',
animationSpeed: 0.5,
})
// Use either provided waves or defaults
// Refs
const container = ref<HTMLElement | null>(null)
const svg = ref<SVGSVGElement | null>(null)
// Reactive Variables
const svgWidth = ref(0)
const waveHeight = ref(props.height)
const waveAmplitude = ref(props.amplitude)
const waveLength = ref(props.waveLength)
const wavePath = ref('')
const waveFillColor = ref(props.fillColor)
const direction = ref<'up' | 'down'>(props.direction)
// Function to generate the SVG sine wave path
function generateSineWavePath(
width: number,
height: number,
amplitude: number,
waveLength: number,
direction: 'up' | 'down',
): string {
const points: string[] = []
// Calculate the number of complete waves to fill the SVG width
const numberOfWaves = Math.ceil(width / waveLength)
// Total width covered by all complete waves
const totalWavesWidth = numberOfWaves * waveLength
// Step size in pixels for generating points (1px for precision)
const step = 1
// Determine base Y position based on direction
const baseY = direction === 'up' ? height - amplitude : amplitude
// Start the path at the base Y position
points.push(`M 0 ${baseY}`)
// Generate points for the sine wave
for (let x = 0; x <= totalWavesWidth; x += step) {
const y = direction === 'up'
? baseY - amplitude * Math.sin((2 * Math.PI * x) / waveLength)
: baseY + amplitude * Math.sin((2 * Math.PI * x) / waveLength)
points.push(`L ${x} ${y}`)
}
// Close the path for filling
if (direction === 'up') {
points.push(`L ${totalWavesWidth} ${height}`)
points.push(`L 0 ${height} Z`)
}
else {
points.push(`L ${totalWavesWidth} 0`)
points.push(`L 0 0 Z`)
}
return points.join(' ')
}
// Function to handle container resize
function handleResize() {
if (container.value) {
const width = container.value.clientWidth
svgWidth.value = width
// Calculate the number of waves needed to cover twice the container width
const numberOfWaves = Math.ceil((width * 2) / waveLength.value)
// Total width is exact multiple of waveLength
const totalWavesWidth = numberOfWaves * waveLength.value
// Generate wave path based on the exact total width
wavePath.value = generateSineWavePath(
totalWavesWidth,
waveHeight.value,
waveAmplitude.value,
waveLength.value,
direction.value,
)
// Update SVG width to match the exact multiple
svg.value?.setAttribute('width', totalWavesWidth.toString())
}
}
// Animation Variables
let animationFrameId: number
const animationSpeed = ref(props.animationSpeed)
const animationPosition = ref(0)
// Function to animate the wave
function animateWave() {
animationPosition.value -= animationSpeed.value
if (Math.abs(animationPosition.value) >= waveLength.value) {
animationPosition.value += waveLength.value
}
if (svg.value) {
svg.value.style.transform = `translateX(${animationPosition.value}px)`
}
animationFrameId = requestAnimationFrame(animateWave)
}
watch(
() => [props.height, props.amplitude, props.waveLength, props.fillColor, props.direction],
() => {
waveHeight.value = props.height!
waveAmplitude.value = props.amplitude!
waveLength.value = props.waveLength!
waveFillColor.value = props.fillColor!
direction.value = props.direction!
handleResize() // Regenerate wave path on prop changes
},
{ immediate: true },
)
useEventListener('resize', handleResize)
// Setup on mount
onMounted(() => {
handleResize() // Initial wave generation
animateWave() // Start animation for wave1
})
// Cleanup on unmount
onUnmounted(() => {
cancelAnimationFrame(animationFrameId)
})
</script>
<template>
<div class="relative">
<slot />
<div ref="container" absolute left-0 right-0 top-0 w-full overflow-hidden>
<div v-if="direction === 'down'" :style="{ backgroundColor: waveFillColor, height: `${waveHeight}px` }" w-full />
<svg
ref="svg"
:width="waveLength * Math.ceil((svgWidth * 2) / waveLength)"
:height="waveHeight"
:viewBox="`0 0 ${waveLength * Math.ceil((svgWidth * 2) / waveLength)} ${waveHeight}`"
xmlns="http://www.w3.org/2000/svg"
h="[100%]" w="[200%]"
:style="{ willChange: 'transform' }"
>
<path :d="wavePath" :fill="waveFillColor" />
</svg>
<div v-if="direction === 'up'" :style="{ backgroundColor: waveFillColor, height: `${waveHeight}px` }" w-full />
</div>
</div>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(defineProps<{
values: number[]
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const emit = defineEmits<{
(e: 'update:values', value: number[]): void
(e: 'mousedown', event: MouseEvent): void
}>()
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
const sliderRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const previousIndex = ref<number>(0)
// Utility functions
const asc = (a: number, b: number) => a - b
function findClosest(values: number[], currentValue: number) {
const { index: closestIndex } = values.reduce((acc: { distance: number, index: number } | null, value: number, index: number) => {
const distance = Math.abs(currentValue - value)
if (acc === null || distance < acc.distance || distance === acc.distance) {
return { distance, index }
}
return acc
}, null) || { index: 0 }
return closestIndex
}
function valueToPercent(value: number, min: number, max: number) {
return ((value - min) * 100) / (max - min)
}
function percentToValue(percent: number, min: number, max: number) {
return (max - min) * percent + min
}
function getDecimalPrecision(num: number) {
if (Math.abs(num) < 1) {
const parts = num.toExponential().split('e-')
const matissaDecimalPart = parts[0].split('.')[1]
return (matissaDecimalPart ? matissaDecimalPart.length : 0) + Number.parseInt(parts[1], 10)
}
const decimalPart = num.toString().split('.')[1]
return decimalPart ? decimalPart.length : 0
}
function roundValueToStep(value: number, step: number) {
const nearest = Math.round(value / step) * step
return Number(nearest.toFixed(getDecimalPrecision(step)))
}
// Computed values
const sortedValues = computed(() => {
return [...props.values]
.sort(asc)
.map(value => clamp(value, props.min, props.max))
})
const sliderStyle = computed(() => {
const sliderOffset = valueToPercent(sortedValues.value[0], props.min, props.max)
const sliderLeap = valueToPercent(sortedValues.value[sortedValues.value.length - 1], props.min, props.max) - sliderOffset
return {
left: `${sliderOffset}%`,
width: `${sliderLeap}%`,
backgroundSize: `${sliderLeap}% 100%`,
}
})
// Event handlers
function getNewValue(event: MouseEvent, move = false) {
if (!sliderRef.value)
return { newValue: sortedValues.value, activeIndex: 0 }
const { width, left } = sliderRef.value.getBoundingClientRect()
const percent = (event.clientX - left) / width
let currentValue = percentToValue(percent, props.min, props.max)
currentValue = roundValueToStep(currentValue, props.step)
currentValue = clamp(currentValue, props.min, props.max)
const activeIndex = move ? previousIndex.value : findClosest(sortedValues.value, currentValue)
const newValues = [...sortedValues.value]
newValues[activeIndex] = currentValue
const sortedNewValues = [...newValues].sort(asc)
const newActiveIndex = sortedNewValues.indexOf(currentValue)
previousIndex.value = newActiveIndex
return {
newValue: sortedNewValues,
activeIndex: newActiveIndex,
}
}
function handleMouseDown(event: MouseEvent) {
if (props.disabled)
return
event.preventDefault()
isDragging.value = true
emit('mousedown', event)
const { newValue } = getNewValue(event)
emit('update:values', newValue)
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value || props.disabled)
return
const { newValue } = getNewValue(event, true)
emit('update:values', newValue)
}
function handleMouseUp(_: MouseEvent) {
if (!isDragging.value)
return
isDragging.value = false
}
function handleMouseLeave(event: MouseEvent) {
if (!isDragging.value)
return
handleMouseUp(event)
}
</script>
<template>
<span
ref="sliderRef"
class="range-slider"
:class="{ disabled }"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
>
<span
class="slider-track"
:style="sliderStyle"
/>
<span
v-for="(value, index) in sortedValues"
:key="index"
role="slider"
class="slider-thumb"
:style="{ left: `${valueToPercent(value, min, max)}%` }"
:data-index="index"
/>
</span>
</template>
<style scoped>
.range-slider {
width: 100%;
box-sizing: border-box;
display: inline-block;
cursor: ew-resize;
touch-action: none;
border: 3px solid #4bb9fd;
position: relative;
}
.range-slider.disabled {
cursor: default;
pointer-events: none;
opacity: 0.5;
}
.slider-track {
display: block;
position: relative;
background-color: #4bb9fd;
background-image: linear-gradient(90deg, var(--primary-light), var(--primary-light));
background-repeat: no-repeat;
height: 14px;
}
.slider-thumb {
position: absolute;
width: 10px;
height: 10px;
background: white;
top: 50%;
transform: translate(-50%, -50%);
}
</style>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
interface WaveProps {
verticalOffset?: number // Vertical offset of the wave in pixels
height?: number // Height of the wave in pixels
amplitude?: number // Wave height variation in pixels
waveLength?: number // Length of one wave cycle in pixels
fillColor?: string // Fill color of the wave
direction?: 'up' | 'down'// Direction of the wave: 'up' or 'down'
animationSpeed?: number // Speed of the wave animation in pixels per frame
}
const props = withDefaults(defineProps<WaveProps>(), {
verticalOffset: 20,
height: 40,
amplitude: 14,
waveLength: 250,
fillColor: '#f8e8f2',
direction: 'down',
animationSpeed: 0.5,
})
// Use either provided waves or defaults
// Refs
const container = ref<HTMLElement | null>(null)
const svg = ref<SVGSVGElement | null>(null)
// Reactive Variables
const svgWidth = ref(0)
const waveHeight = ref(props.height)
const waveAmplitude = ref(props.amplitude)
const waveLength = ref(props.waveLength)
const wavePath = ref('')
const waveFillColor = ref(props.fillColor)
const direction = ref<'up' | 'down'>(props.direction)
// Function to generate the SVG sine wave path
function generateSineWavePath(
width: number,
height: number,
amplitude: number,
waveLength: number,
direction: 'up' | 'down',
): string {
const points: string[] = []
// Calculate the number of complete waves to fill the SVG width
const numberOfWaves = Math.ceil(width / waveLength)
// Total width covered by all complete waves
const totalWavesWidth = numberOfWaves * waveLength
// Step size in pixels for generating points (1px for precision)
const step = 1
// Determine base Y position based on direction
const baseY = direction === 'up' ? height - amplitude : amplitude
// Start the path at the base Y position
points.push(`M 0 ${baseY}`)
// Generate points for the sine wave
for (let x = 0; x <= totalWavesWidth; x += step) {
const y = direction === 'up'
? baseY - amplitude * Math.sin((2 * Math.PI * x) / waveLength)
: baseY + amplitude * Math.sin((2 * Math.PI * x) / waveLength)
points.push(`L ${x} ${y}`)
}
// Close the path for filling
if (direction === 'up') {
points.push(`L ${totalWavesWidth} ${height}`)
points.push(`L 0 ${height} Z`)
}
else {
points.push(`L ${totalWavesWidth} 0`)
points.push(`L 0 0 Z`)
}
return points.join(' ')
}
// Function to handle container resize
function handleResize() {
if (container.value) {
const width = container.value.clientWidth
svgWidth.value = width
// Calculate the number of waves needed to cover twice the container width
const numberOfWaves = Math.ceil((width * 2) / waveLength.value)
// Total width is exact multiple of waveLength
const totalWavesWidth = numberOfWaves * waveLength.value
// Generate wave path based on the exact total width
wavePath.value = generateSineWavePath(
totalWavesWidth,
waveHeight.value,
waveAmplitude.value,
waveLength.value,
direction.value,
)
// Update SVG width to match the exact multiple
svg.value?.setAttribute('width', totalWavesWidth.toString())
}
}
// Animation Variables
let animationFrameId: number
const animationSpeed = ref(props.animationSpeed)
const animationPosition = ref(0)
// Function to animate the wave
function animateWave() {
animationPosition.value -= animationSpeed.value
if (Math.abs(animationPosition.value) >= waveLength.value) {
animationPosition.value += waveLength.value
}
if (svg.value) {
svg.value.style.transform = `translateX(${animationPosition.value}px)`
}
animationFrameId = requestAnimationFrame(animateWave)
}
watch(
() => [props.height, props.amplitude, props.waveLength, props.fillColor, props.direction],
() => {
waveHeight.value = props.height!
waveAmplitude.value = props.amplitude!
waveLength.value = props.waveLength!
waveFillColor.value = props.fillColor!
direction.value = props.direction!
handleResize() // Regenerate wave path on prop changes
},
{ immediate: true },
)
useEventListener('resize', handleResize)
// Setup on mount
onMounted(() => {
handleResize() // Initial wave generation
animateWave() // Start animation for wave1
})
// Cleanup on unmount
onUnmounted(() => {
cancelAnimationFrame(animationFrameId)
})
</script>
<template>
<div class="relative">
<slot />
<div ref="container" absolute left-0 right-0 top-0 w-full overflow-hidden>
<div v-if="direction === 'down'" :style="{ backgroundColor: waveFillColor, height: `${waveHeight}px` }" w-full />
<svg
ref="svg"
:width="waveLength * Math.ceil((svgWidth * 2) / waveLength)"
:height="waveHeight"
:viewBox="`0 0 ${waveLength * Math.ceil((svgWidth * 2) / waveLength)} ${waveHeight}`"
xmlns="http://www.w3.org/2000/svg"
h="[100%]" w="[200%]"
:style="{ willChange: 'transform' }"
>
<path :d="wavePath" :fill="waveFillColor" />
</svg>
<div v-if="direction === 'up'" :style="{ backgroundColor: waveFillColor, height: `${waveHeight}px` }" w-full />
</div>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import DesktopSettings from '../Widgets/DesktopSettings.vue'
</script>
<template>
<header mb-1 w-full gap-2>
<a
href="https://github.com/moeru-ai/airi" rel="noreferrer noopener" target="_blank" flex="~ 1" w-full items-center
gap-2 px-2 text-nowrap text-2xl outline-none
>
<div i-solar:cat-outline text="[#ed869d]" />
<div font-cute>
<span>アイリ</span>
</div>
</a>
<DesktopSettings />
</header>
</template>
@@ -0,0 +1,298 @@
<script setup lang="ts">
import { BasicTextarea, TransitionVertical } from '@proj-airi/stage-ui/components'
import { useMicVAD, useWhisper } from '@proj-airi/stage-ui/composables'
import WhisperWorker from '@proj-airi/stage-ui/libs/workers/worker?worker&url'
import { useAudioContext, useChatStore, useSettings } from '@proj-airi/stage-ui/stores'
import { useDevicesList } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { encodeWAVToBase64 } from '../../utils/binary'
import ChatHistory from '../Widgets/ChatHistory.vue'
const messageInput = ref('')
const listening = ref(false)
const tab = ref<'chat' | 'custom' | 'clothes'>('chat')
const showMicrophoneSelect = ref(false)
const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { audioContext } = useAudioContext()
const { t } = useI18n()
const { transcribe: generate, load: loadWhisper, status: whisperStatus, terminate } = useWhisper(WhisperWorker, {
onComplete: async (res) => {
await send(res)
},
})
async function handleSend() {
await send(messageInput.value)
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
onSpeechStart: () => {
// TODO: interrupt the playback
// TODO: interrupt any of the ongoing TTS
// TODO: interrupt any of the ongoing LLM requests
// TODO: interrupt any of the ongoing animation of Live2D or VRM
// TODO: once interrupted, we should somehow switch to listen or thinking
// emotion / expression?
listening.value = true
},
// VAD misfire means while speech end is detected but
// the frames of the segment of the audio buffer
// is not enough to be considered as a speech segment
// which controlled by the `minSpeechFrames` parameter
onVADMisfire: () => {
// TODO: do audio buffer send to whisper
listening.value = false
},
onSpeechEnd: (buffer) => {
// TODO: do audio buffer send to whisper
listening.value = false
handleTranscription(buffer)
},
auto: false,
})
function handleLoadWhisper() {
if (whisperStatus.value === 'loading')
return
loadWhisper()
start()
}
async function handleTranscription(buffer: Float32Array) {
await audioContext.resume()
// Convert Float32Array to WAV format
const audioBase64 = await encodeWAVToBase64(buffer, audioContext.sampleRate)
generate({ type: 'generate', data: { audio: audioBase64, language: 'en' } })
}
async function handleAudioInputChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = audioInputs.value.find(d => d.deviceId === target.value)
if (!found) {
selectedAudioDevice.value = undefined
return
}
selectedAudioDevice.value = found
}
watch(isAudioInputOn, async (value) => {
if (value === 'false') {
destroy()
terminate()
}
})
onAfterSend(async () => {
messageInput.value = ''
})
</script>
<template>
<div flex="col" items-center pt-4>
<fieldset flex="~ row" w-fit rounded-lg>
<label
:class="[
tab === 'chat' ? 'bg-pink-100 dark:bg-[#3c2632]' : 'bg-white dark:bg-[#121212]',
tab === 'chat' ? 'text-pink-500 dark:text-pink-500' : '',
]"
flex="~ row"
:checked="tab === 'chat'"
:aria-checked="tab === 'chat'"
border="solid l-2 t-2 b-2 pink-100 dark:[#3c2632]"
bg="hover:pink-100 dark:hover:[#3c2632]"
text="pink-300 hover:pink-500 dark:pink-300/50 dark:hover:pink-500"
transition="all duration-250 ease-in-out"
cursor-pointer items-center gap-1 rounded-l-lg px-2
>
<input v-model="tab" type="radio" name="tab" value="chat" hidden>
<div i-solar:dialog-2-bold-duotone text="2xl" transform="translate-y--2" />
<div flex="~ row" items-center>
<span min-w="3em">{{ $t('stage.chat.tabs.chat') }}</span>
</div>
</label>
<label
:class="[
tab === 'custom' ? 'bg-pink-100 dark:bg-[#3c2632]' : 'bg-white dark:bg-[#121212]',
tab === 'custom' ? 'text-pink-500 dark:text-pink-500' : '',
]"
flex="~ row"
:checked="tab === 'custom'"
:aria-checked="tab === 'custom'"
border="solid t-2 b-2 pink-100 dark:[#3c2632]"
bg="hover:pink-100 dark:hover:[#3c2632]"
text="pink-300 hover:pink-500 dark:pink-300/50 dark:hover:pink-500"
transition="all duration-250 ease-in-out"
cursor-pointer items-center gap-1 px-2
>
<input v-model="tab" type="radio" name="tab" value="custom" hidden>
<div i-solar:star-fall-2-bold-duotone text="2xl" transform="translate-y--2" />
<div flex="~ row" items-center>
<span>{{ $t('stage.chat.tabs.custom') }}</span>
</div>
</label>
<label
:class="[
tab === 'clothes' ? 'bg-pink-100 dark:bg-[#3c2632]' : 'bg-white dark:bg-[#121212]',
tab === 'clothes' ? 'text-pink-500 dark:text-pink-500' : '',
]"
flex="~ row"
:checked="tab === 'clothes'"
:aria-checked="tab === 'clothes'"
border="solid r-2 t-2 b-2 pink-100 dark:[#3c2632]"
bg="hover:pink-100 dark:hover:[#3c2632]"
text="pink-300 hover:pink-500 dark:pink-300/50 dark:hover:pink-500"
transition="all duration-250 ease-in-out"
cursor-pointer items-center gap-1 rounded-r-lg px-2
>
<input v-model="tab" type="radio" name="tab" value="clothes" hidden>
<div i-solar:magic-stick-3-bold-duotone text="2xl" transform="translate-y--2" />
<div flex="~ row" items-center>
<span>{{ $t('stage.chat.tabs.clothes') }}</span>
</div>
</label>
</fieldset>
<div h-full max-h="[85vh]" w-full px="12 <md:0" py="4">
<div
flex="~ col"
border="solid 4 pink-100 dark:pink-400/20"
h-full w-full overflow-scroll rounded-xl
bg="white dark:[#0f060c]"
>
<ChatHistory h-full flex-1 p-4 w="full" max-h="<md:[60%]" />
<div h="<md:full" flex gap-2>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
text="pink-300 hover:pink-500 dark:pink-300/50 dark:hover:pink-500 placeholder:pink-300 placeholder:hover:pink-500 placeholder:dark:pink-300/50 placeholder:dark:hover:pink-500"
bg="pink-100 dark:pink-400/20"
min-h="[100px]" w-full
rounded-t-xl p-4 font-medium
outline-none transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@submit="handleSend"
/>
</div>
</div>
</div>
<div flex="~ row" gap-2>
<button
bg="cyan-100 hover:cyan-200 dark:cyan-800 dark:hover:cyan-700"
transition="all duration-250 ease-in-out"
text="cyan-400"
mb-6 flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
@click="handleLoadWhisper"
>
<Transition mode="out-in">
<div v-if="whisperStatus === null" flex="~ row" items-center justify-center space-x-1>
{{ $t('stage.operations.load-models') }}
</div>
<div v-else-if="whisperStatus === 'loading'" flex="~ row" items-center justify-center space-x-1>
<div i-svg-spinners:bouncing-ball class="text-cyan" />
<span>{{ $t('stage.operations.load-models-status.loading') }}</span>
</div>
<div v-else-if="whisperStatus === 'ready'" flex="~ row" items-center justify-center space-x-1>
<div i-lucide:check class="text-cyan" />
<span>{{ $t('stage.operations.load-models-status.ready') }}</span>
</div>
</Transition>
</button>
<div flex="~ row" relative text-white font-bold>
<TransitionVertical>
<fieldset
v-if="showMicrophoneSelect"
transform="translate-y--100%" right="-50%" bottom="-10" text="cyan-400 dark:white" bg="white dark:cyan-900" border="solid 4 cyan-200 dark:cyan-800"
absolute z-30 rounded-2xl px-2 py-2 text-right text-nowrap text-base font-sans
>
<label v-for="(input, index) in audioInputs" :key="index" class="[&_div_span]:dark:hover:bg-cyan-300 [&_div_span]:dark:hover:bg-cyan-900">
<input type="radio" name="audioInput" :value="input.deviceId" hidden @change="handleAudioInputChange">
<div flex="~ row" cursor-pointer items-center gap-2 grid="cols-2">
<div min-w="6">
<div v-if="input.deviceId === selectedAudioDeviceId" i-solar:check-circle-line-duotone />
</div>
<span
inline-block
:class="[input.deviceId === selectedAudioDeviceId ? 'cyan-400 dark:text-white' : 'cyan-400/50 dark:text-white/50']"
transition="all duration-250 ease-in-out"
>
{{ input.label }}
</span>
</div>
</label>
</fieldset>
</TransitionVertical>
<label
bg="cyan-100 hover:cyan-200 dark:cyan-800 dark:hover:cyan-700"
transition="all duration-250 ease-in-out"
text="cyan-400"
mb-6 flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
>
<input v-model="showMicrophoneSelect" type="checkbox" hidden>
<div i-solar:microphone-2-bold-duotone />
<div>
<span v-if="!listening">{{ $t('settings.microphone') }}</span>
<span v-else>Listening...</span>
</div>
</label>
</div>
</div>
</div>
</template>
<style lang="css" scoped>
/**
Plunker - Untitled
https://plnkr.co/edit/4wPv1ogKNMfJ6rQPhZdJ?p=preview&preview
by https://stackoverflow.com/a/31547711/19954520
*/
.animate-stripe {
background-image: repeating-linear-gradient(-45deg, #a16207, #a16207 25px, #eab308 25px, #eab308 50px);
background-size: 175% 100%;
}
.animate-stripe:hover {
animation: progress 2s linear infinite;
}
@-webkit-keyframes progress {
0% {
background-position: 0 0;
}
100% {
background-position: -75px 0px;
}
}
@-moz-keyframes progress {
0% {
background-position: 0 0;
}
100% {
background-position: -75px 0px;
}
}
@-ms-keyframes progress {
0% {
background-position: 0 0;
}
100% {
background-position: -75px 0px;
}
}
@keyframes progress {
0% {
background-position: 0 0;
}
100% {
background-position: -70px 0px;
}
}
</style>
@@ -0,0 +1,10 @@
<template>
<header mb-1 w-full gap-2>
<div flex="~ 1" w-full items-center justify-center gap-2 px-2 text-nowrap text-lg>
<div i-solar:cat-outline text="[#ed869d]" />
<div font-cute>
<span>アイリ</span>
</div>
</div>
</header>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { BasicTextarea } from '@proj-airi/stage-ui/components'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import MobileChatHistory from '../Widgets/MobileChatHistory.vue'
import MobileSettings from '../Widgets/MobileSettings.vue'
const messageInput = ref('')
const listening = ref(false)
// const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { t } = useI18n()
async function handleSend() {
if (!messageInput.value.trim()) {
return
}
await send(messageInput.value)
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
onSpeechStart: () => {
// TODO: interrupt the playback
// TODO: interrupt any of the ongoing TTS
// TODO: interrupt any of the ongoing LLM requests
// TODO: interrupt any of the ongoing animation of Live2D or VRM
// TODO: once interrupted, we should somehow switch to listen or thinking
// emotion / expression?
listening.value = true
},
// VAD misfire means while speech end is detected but
// the frames of the segment of the audio buffer
// is not enough to be considered as a speech segment
// which controlled by the `minSpeechFrames` parameter
onVADMisfire: () => {
// TODO: do audio buffer send to whisper
listening.value = false
},
onSpeechEnd: (buffer) => {
// TODO: do audio buffer send to whisper
listening.value = false
handleTranscription(buffer)
},
auto: false,
})
function handleTranscription(_buffer: Float32Array<ArrayBufferLike>) {
// eslint-disable-next-line no-alert
alert('Transcription is not implemented yet')
}
// async function handleAudioInputChange(event: Event) {
// const target = event.target as HTMLSelectElement
// const found = audioInputs.value.find(d => d.deviceId === target.value)
// if (!found) {
// selectedAudioDevice.value = undefined
// return
// }
// selectedAudioDevice.value = found
// }
watch(isAudioInputOn, async (value) => {
if (value === 'false') {
destroy()
}
})
onAfterSend(async () => {
messageInput.value = ''
})
onMounted(() => {
start()
})
</script>
<template>
<div>
<div relative w-full flex gap-1>
<MobileChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 pink-100 dark:pink-400/20"
text="pink-400 hover:pink-600 dark:[#905073] dark:hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600 placeholder:dark:[#905073] placeholder:dark:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]"
w-full resize-none overflow-y-scroll rounded-l-xl p-2 font-medium outline-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
@submit="handleSend"
/>
</div>
<DrawerRoot should-scale-background>
<DrawerTrigger
class="px-4 py-2.5"
border="solid 2 pink-100 dark:pink-400/20"
text="lg pink-400 hover:pink-600 dark:[#905073] dark:hover:pink-600 placeholder:pink-400 placeholder:hover:pink-600 placeholder:dark:[#905073] placeholder:dark:hover:pink-600"
bg="pink-50 dark:[#3c2632]" max-h="[10lh]" min-h="[1lh]" rounded-r-xl
>
<div i-solar:settings-bold-duotone />
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay class="fixed inset-0 z-50 bg-black/40" />
<DrawerContent
max-h="[75%]"
fixed bottom-0 left-0 right-0 z-50 mt-24 h-full flex flex-col rounded-t-lg bg="[#fffbff] dark:[#1f1a1d]"
>
<div class="flex flex-1 flex-col rounded-t-lg p-5" bg="[#fffbff] dark:[#1f1a1d]" gap-2>
<MobileSettings />
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</div>
</div>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
</script>
<template>
<div
relative px="<sm:2" py="<sm:2" flex="~ col" rounded="lg" overflow-hidden
>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full flex="~ col" overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200/50 dark:pink-500/50"
shadow="md pink-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1
h="unset <sm:fit"
bg="<md:pink-500/25"
>
<div>
<span text-xs text="pink-400/90 dark:pink-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.airi') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
<div
flex="~ col"
border="4 solid cyan-200/50 dark:cyan-500/50"
shadow="md cyan-200/50 dark:none"
px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1
bg="<md:cyan-500/25"
>
<div>
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.you') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { DrawerContent, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
import MobileSettings from './MobileSettings.vue'
</script>
<template>
<div flex="~" gap-2>
<DrawerRoot should-scale-background direction="right">
<DrawerTrigger
bg="zinc-100 dark:zinc-800"
text="lg zinc-500 dark:zinc-400"
max-h="[10lh]" min-h="[1lh]"
m-1 rounded-lg p-2 outline-none
>
<div i-solar:settings-minimalistic-bold-duotone />
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay fixed inset-0 z-50 bg-black:40 />
<DrawerContent
class="max-w-40% min-w-500px w-full"
flex="~ col"
bg="white dark:zinc-900"
fixed inset-y-4 right-4 z-50 of-hidden rounded-lg
>
<div flex="~ 1 col gap-2" of-y-scroll rounded-t-lg p-5>
<MobileSettings />
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
})
})
</script>
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="30vh" flex="~ col" overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200/50 dark:pink-500/50"
shadow="md pink-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1
h="unset <sm:fit"
bg="<md:pink-500/25"
>
<div>
<span text-xs text="pink-400/90 dark:pink-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.airi') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~">
<div
flex="~ col"
border="4 solid cyan-200/50 dark:cyan-500/50"
shadow="md cyan-200/50 dark:none"
px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1
bg="<md:cyan-500/25"
>
<div>
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.you') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import type { Voice } from '@proj-airi/stage-ui/constants'
import { voiceList } from '@proj-airi/stage-ui/constants'
import { useLLM, useSettings } from '@proj-airi/stage-ui/stores'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const settings = useSettings()
const dark = useDark({ disableTransition: false })
const supportedModels = ref<{ id: string, name?: string }[]>([])
const { models } = useLLM()
const { openAiModel, openAiApiBaseURL, openAiApiKey, elevenlabsVoiceEnglish, elevenlabsVoiceJapanese } = storeToRefs(settings)
function handleModelChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = supportedModels.value.find(m => m.id === target.value)
if (!found) {
openAiModel.value = undefined
return
}
openAiModel.value = found
}
function handleViewChange(event: Event) {
const target = event.target as HTMLSelectElement
settings.stageView = target.value
}
function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as Voice
switch (locale.value) {
case 'en':
case 'en-US':
elevenlabsVoiceEnglish.value = value
break
case 'zh':
case 'zh-CN':
case 'zh-TW':
case 'zh-HK':
elevenlabsVoiceEnglish.value = value
break
case 'jp':
case 'jp-JP':
elevenlabsVoiceJapanese.value = value
break
}
}
watch([openAiApiBaseURL, openAiApiKey], async ([baseUrl, apiKey]) => {
if (!baseUrl || !apiKey) {
supportedModels.value = []
return
}
supportedModels.value = await models(baseUrl, apiKey)
})
onMounted(async () => {
if (!openAiApiBaseURL.value || !openAiApiKey.value)
return
supportedModels.value = await models(openAiApiBaseURL.value, openAiApiKey.value)
})
</script>
<template>
<div text="zinc-500 dark:zinc-400">
<h2 text="zinc-800/80 dark:zinc-200/80 xl" font-bold>
{{ t('settings.title') }}
</h2>
<div>
<div
grid="~ cols-[150px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="zinc-100 dark:zinc-800" px-2 py-1
>
<div text="sm" pl-1>
<span>{{ t('settings.openai-base-url.label') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<input
v-model="settings.openAiApiBaseURL"
text="zinc-800 dark:zinc-100"
type="text"
:placeholder="t('settings.openai-base-url.placeholder_mobile')"
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="sm" pl-1>
<span>{{ t('settings.openai-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<input
v-model="settings.openAiApiKey"
text="zinc-800 dark:zinc-100"
type="text"
:placeholder="t('settings.openai-api-key.placeholder_mobile')"
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="sm" pl-1>
<span>{{ t('settings.elevenlabs-api-key.label') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<input
v-model="settings.elevenLabsApiKey"
text="zinc-800 dark:zinc-100"
type="text"
:placeholder="t('settings.elevenlabs-api-key.placeholder_mobile')"
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
>
</div>
<div text="sm" pl-1>
<span>{{ t('settings.language.title') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<select
v-model="settings.language"
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
text="zinc-800 dark:zinc-100"
>
<option value="en-US">
{{ t('settings.language.english') }}
</option>
<option value="zh-CN">
{{ t('settings.language.chinese') }}
</option>
</select>
</div>
<div text="sm" pl-1>
<span>{{ t('settings.models') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<select
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
text="zinc-800 dark:zinc-100"
@change="handleModelChange"
>
<option disabled class="bg-white dark:bg-zinc-800">
{{ t('stage.select-a-model') }}
</option>
<option v-if="settings.openAiModel" :value="settings.openAiModel.id">
{{ 'name' in settings.openAiModel ? `${settings.openAiModel.name} (${settings.openAiModel.id})` : settings.openAiModel.id }}
</option>
<option v-for="m in supportedModels" :key="m.id" :value="m.id">
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
</option>
</select>
</div>
<div text="sm" pl-1>
<span>{{ t('settings.voices') }}</span>
</div>
<div flex="~ row" w-full text="sm">
<select
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
text="zinc-800 dark:zinc-100"
@change="handleVoiceChange"
>
<option disabled class="bg-white dark:bg-zinc-800">
{{ t('stage.select-a-voice') }}
</option>
<option v-if="['en', 'en-US'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<!-- TODO -->
<option v-if="['zh', 'zh-CN', 'zh-TW', 'zh-HK'].indexOf(locale) !== -1 && elevenlabsVoiceEnglish" :value="elevenlabsVoiceEnglish">
{{ elevenlabsVoiceEnglish }}
</option>
<option v-if="['jp', 'jp-JP'].indexOf(locale) !== -1 && elevenlabsVoiceJapanese" :value="elevenlabsVoiceJapanese">
{{ elevenlabsVoiceJapanese }}
</option>
<option v-for="(m, index) in voiceList[locale]" :key="index" :value="m">
{{ m }}
</option>
</select>
</div>
</div>
</div>
<h2 text="zinc-800/80 dark:zinc-200/80 xl" font-bold>
View
</h2>
<div>
<div
grid="~ cols-[140px_1fr]" my-2 items-center gap-1.5 rounded-lg
bg="zinc-100 dark:zinc-800" px-2 py-1
>
<div text="sm" pl-1>
<span>Viewer</span>
</div>
<select
h-8 w-full rounded-md bg-transparent px-2 py-1 text-right font-mono outline-none
text="zinc-800 dark:zinc-100"
@change="handleViewChange"
>
<option value="2d">
2D
</option>
<option value="3d">
3D
</option>
</select>
<div text="sm" pl-1>
<span>Theme</span>
</div>
<label h-8 flex cursor-pointer items-center justify-end>
<input
v-model="dark"
text="zinc-800 dark:zinc-100"
:checked="dark"
:aria-checked="dark"
name="stageView"
type="checkbox"
hidden appearance-none outline-none
>
<div select-none>
<Transition name="slide-away" mode="out-in">
<div v-if="dark" i-solar:sun-fog-bold-duotone transition="all ease-in-out duration-250" />
<div v-else i-solar:moon-stars-bold-duotone transition="all ease-in-out duration-250" />
</Transition>
</div>
</label>
</div>
</div>
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
export const appName = 'アイリ VTuber'
export const appDescription = 'アイリ VTuber - LLM Powered Live2D VTuber'
+7
View File
@@ -0,0 +1,7 @@
declare interface Window {
electron: {
ipcRenderer: {
send: (channel: string, ...args: any[]) => void
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useScreenSafeArea } from '@vueuse/core'
const { top, right, bottom, left } = useScreenSafeArea()
</script>
<template>
<main
text="gray-700 dark:gray-200" font-cuteen h-full
:style="{
paddingTop: `${top}px`,
paddingRight: `${right}px`,
paddingBottom: `${bottom}px`,
paddingLeft: `${left}px`,
}"
>
<RouterView />
</main>
</template>
+55
View File
@@ -0,0 +1,55 @@
import type { Router } from 'vue-router'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import Tres from '@tresjs/core'
import { MotionPlugin } from '@vueuse/motion'
import NProgress from 'nprogress'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import '@unocss/reset/tailwind.css'
import './styles/main.css'
import 'uno.css'
const pinia = createPinia()
const routeRecords = setupLayouts(routes)
let router: Router
if (import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)
router = createRouter({ routes: routeRecords, history: createWebHashHistory() })
else
router = createRouter({ routes: routeRecords, history: createWebHistory() })
router.beforeEach((to, from) => {
if (to.path !== from.path)
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})
router.isReady()
.then(async () => {
if (import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE) {
return
}
const { registerSW } = await import('virtual:pwa-register')
registerSW({ immediate: true })
})
.catch(() => {})
createApp(App)
.use(MotionPlugin)
.use(autoAnimatePlugin)
.use(router)
.use(pinia)
.use(i18n)
.use(Tres)
.mount('#app')
+24
View File
@@ -0,0 +1,24 @@
import messages from '@intlify/unplugin-vue-i18n/messages'
import { createI18n } from 'vue-i18n'
export const i18n = createI18n({
legacy: false,
locale: getLocale(),
fallbackLocale: 'en',
messages,
})
function getLocale() {
const language = localStorage.getItem('settings/language')
const languages = Object.keys(messages!)
if (language && languages.includes(language))
return language
// let locale = navigator.language
// if (locale === 'zh')
// locale = 'zh-CN'
return 'en'
}
+5
View File
@@ -0,0 +1,5 @@
<template>
<div>
404 - Page not found
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
const containerRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
function handleFileUpload(e: Event) {
if (!e)
return
const file = fileInputRef.value?.files?.[0]
if (!file)
return
const audioElem = document.createElement('audio')
containerRef.value?.appendChild(audioElem)
audioElem.src = URL.createObjectURL(file)
audioElem.controls = true
audioElem.load()
audioElem.play()
}
</script>
<template>
<div>
<div>
<div ref="containerRef" />
<input
ref="fileInputRef"
type="file"
@change="handleFileUpload"
>
</div>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More