refactor(apps): move all apps into apps directory
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -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>
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Sample rate of the audio.
|
||||
* Coindicentally, this is the same for both models (Moonshine and Silero VAD)
|
||||
*/
|
||||
export const SAMPLE_RATE = 16000
|
||||
export const SAMPLE_RATE_MS = SAMPLE_RATE / 1000
|
||||
|
||||
/**
|
||||
* Probabilities ABOVE this value are considered as SPEECH
|
||||
*/
|
||||
export const SPEECH_THRESHOLD = 0.3
|
||||
|
||||
/**
|
||||
* If current state is SPEECH, and the probability of the next state
|
||||
* is below this value, it is considered as NON-SPEECH.
|
||||
*/
|
||||
export const EXIT_THRESHOLD = 0.1
|
||||
|
||||
/**
|
||||
* After each speech chunk, wait for at least this amount of silence
|
||||
* before considering the next chunk as a new speech chunk
|
||||
*/
|
||||
export const MIN_SILENCE_DURATION_MS = 400
|
||||
export const MIN_SILENCE_DURATION_SAMPLES
|
||||
= MIN_SILENCE_DURATION_MS * SAMPLE_RATE_MS
|
||||
|
||||
/**
|
||||
* Pad the speech chunk with this amount each side
|
||||
*/
|
||||
export const SPEECH_PAD_MS = 80
|
||||
export const SPEECH_PAD_SAMPLES = SPEECH_PAD_MS * SAMPLE_RATE_MS
|
||||
|
||||
/**
|
||||
* Final speech chunks below this duration are discarded
|
||||
*/
|
||||
export const MIN_SPEECH_DURATION_SAMPLES = 250 * SAMPLE_RATE_MS // 250 ms
|
||||
|
||||
/**
|
||||
* Maximum duration of audio that can be handled by Moonshine
|
||||
*/
|
||||
export const MAX_BUFFER_DURATION = 30
|
||||
|
||||
/**
|
||||
* Size of the incoming buffers
|
||||
*/
|
||||
export const NEW_BUFFER_SIZE = 512
|
||||
|
||||
/**
|
||||
* The number of previous buffers to keep, to ensure the audio is padded correctly
|
||||
*/
|
||||
export const MAX_NUM_PREV_BUFFERS = Math.ceil(
|
||||
SPEECH_PAD_SAMPLES / NEW_BUFFER_SIZE,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
const MIN_CHUNK_SIZE = 512
|
||||
let globalPointer = 0
|
||||
const globalBuffer = new Float32Array(MIN_CHUNK_SIZE)
|
||||
|
||||
class VADProcessor extends AudioWorkletProcessor {
|
||||
process(inputs: Float32Array[][], _outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
|
||||
const buffer = inputs[0][0]
|
||||
if (!buffer)
|
||||
return false // buffer is null when the stream ends
|
||||
|
||||
if (buffer.length > MIN_CHUNK_SIZE) {
|
||||
// If the buffer is larger than the minimum chunk size, send the entire buffer
|
||||
this.port.postMessage({ buffer })
|
||||
}
|
||||
else {
|
||||
const remaining = MIN_CHUNK_SIZE - globalPointer
|
||||
if (buffer.length >= remaining) {
|
||||
// If the buffer is larger than (or equal to) the remaining space in the global buffer, copy the remaining space
|
||||
globalBuffer.set(buffer.subarray(0, remaining), globalPointer)
|
||||
|
||||
// Send the global buffer
|
||||
this.port.postMessage({ buffer: globalBuffer })
|
||||
|
||||
// Reset the global buffer and set the remaining buffer
|
||||
globalBuffer.fill(0)
|
||||
globalBuffer.set(buffer.subarray(remaining), 0)
|
||||
globalPointer = buffer.length - remaining
|
||||
}
|
||||
else {
|
||||
// If the buffer is smaller than the remaining space in the global buffer, copy the buffer to the global buffer
|
||||
globalBuffer.set(buffer, globalPointer)
|
||||
globalPointer += buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
return true // Keep the processor alive
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('vad-processor', VADProcessor)
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import Tres from '@tresjs/core'
|
||||
import { MotionPlugin } from '@vueuse/motion'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './styles/main.css'
|
||||
import 'uno.css'
|
||||
|
||||
createApp(App)
|
||||
.use(MotionPlugin)
|
||||
.use(Tres)
|
||||
.mount('#app')
|
||||
@@ -0,0 +1,18 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function formatDate(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleString('zh', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
fractionalSecondDigits: 3,
|
||||
})
|
||||
}
|
||||
|
||||
export async function supportsWebGPU() {
|
||||
try {
|
||||
if (!('gpu' in navigator) || !navigator.gpu)
|
||||
return false
|
||||
|
||||
await navigator.gpu.requestAdapter()
|
||||
return true
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]))
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
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,
|
||||
})
|
||||
@@ -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'),
|
||||
],
|
||||
})
|
||||
@@ -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: ['退出', '取消'],
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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?
|
||||
]
|
||||
}
|
||||
@@ -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}',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]))
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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: 等待中
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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>
|
||||
|
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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const appName = 'アイリ VTuber'
|
||||
export const appDescription = 'アイリ VTuber - LLM Powered Live2D VTuber'
|
||||
@@ -0,0 +1,7 @@
|
||||
declare interface Window {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, ...args: any[]) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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,5 @@
|
||||
<template>
|
||||
<div>
|
||||
404 - Page not found
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||