feat(stage-web|stage-tamagotchi): speech finished

This commit is contained in:
Neko Ayaka
2025-03-19 18:45:36 +08:00
parent 76483a3740
commit fb859ddfcc
27 changed files with 3012 additions and 525 deletions
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Project AIRI Real-time Audio Playground</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" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/realtime-audio... run build"
publish = "/apps/realtime-audio/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@proj-airi/realtime-audio",
"type": "module",
"private": true,
"description": "Realtime audio",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "apps/realtime-audio"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@vueuse/core": "^13.0.0",
"@xsai/generate-text": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"defu": "^6.1.4",
"es-toolkit": "^1.33.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@iconify-json/solar": "^1.2.2",
"@unocss/reset": "^66.1.0-beta.5",
"@vitejs/plugin-vue": "^5.2.3",
"superjson": "^2.2.2",
"unplugin-vue-router": "^0.12.0",
"vite": "^6.2.2",
"vue-router": "^4.5.0",
"vue-tsc": "^3.0.0-alpha.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
import { RouterLink, RouterView } from 'vue-router'
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
<template>
<div mx-auto max-w-screen-lg flex flex-col gap-2 p-4>
<header flex flex-row items-center justify-between>
<h1 text-2xl>
Real-time Audio Playground
</h1>
<div flex flex-row items-center gap-2>
<button text-lg @click="() => toggleDark()">
<div v-if="isDark" i-solar:moon-stars-bold-duotone />
<div v-else i-solar:sun-bold />
</button>
<a href="https://github.com/moeru-ai/airi/tree/main/apps/realtime-audio">
<div i-simple-icons:github />
</a>
</div>
</header>
<nav bg="neutral-100 dark:neutral-800" w-fit flex items-center of-hidden rounded-lg>
<RouterLink
to="/" px-3 py-2 bg="hover:neutral-200 dark:hover:neutral-700"
transition="all duration-250 ease-in-out"
>
<h1>ElevenLabs</h1>
</RouterLink>
<div bg="neutral-200 dark:neutral-600" h="1lh" w="0.5" />
<RouterLink
to="/microsoft" px-3 py-2 bg="hover:neutral-200 dark:hover:neutral-700"
transition="all duration-250 ease-in-out"
>
<h1>Microsoft / Azure</h1>
</RouterLink>
</nav>
<RouterView />
</div>
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
background: #fff;
transition: all 0.3s ease-in-out;
}
html.dark {
background: #121212;
color-scheme: dark;
}
</style>
@@ -0,0 +1,114 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
export interface HandlerContext<T> {
data: T
itemsToBeProcessed: () => number
emit: (eventName: string, ...params: any[]) => void
}
// FIXME: should be exported?
export interface Events<T> {
add: Array<(payload: T) => void>
pick: Array<(payload: T) => void>
processing: Array<(payload: T, handler: (param: HandlerContext<T>) => Promise<any>) => void>
error: Array<(payload: T, error: Error, handler: (param: HandlerContext<T>) => Promise<any>) => void>
processed: Array<<R>(payload: T, result: R, handler: (param: HandlerContext<T>) => Promise<any>) => void>
done: Array<(payload: T) => void>
}
export function useQueue<T>(options: {
handlers: Array<(ctx: HandlerContext<T>) => Promise<void>>
}) {
const queue = ref<T[]>([]) as Ref<T[]>
const isProcessing = ref(false)
const internalEventHandler: Events<T> = {
add: [],
pick: [],
processing: [],
error: [],
processed: [],
done: [],
}
const internalHandlerEventHandler: Record<string, Array<(...params: any[]) => void>> = {}
function on<E extends keyof Events<T>>(eventName: E, handler: Events<T>[E][number]) {
internalEventHandler[eventName].push(handler as any)
}
function emit<E extends keyof Events<T>>(eventName: E, ...params: Parameters<Events<T>[E][number]>) {
const handlers = internalEventHandler[eventName] as Events<T>[E]
handlers.forEach((handler) => {
(handler as any)(...params)
})
}
function onHandlerEvent(eventName: string, handler: (...params: any[]) => void) {
internalHandlerEventHandler[eventName] = internalHandlerEventHandler[eventName] || []
internalHandlerEventHandler[eventName].push(handler)
}
function emitHandlerEvent(eventName: string, ...params: any[]) {
const handlers = internalHandlerEventHandler[eventName] || []
handlers.forEach((handler) => {
handler(...params)
})
}
async function add(payload: T) {
queue.value.push(payload)
emit('add', payload)
}
function pick() {
const payload = queue.value.shift()
if (!payload)
return
emit('pick', payload)
return payload
}
async function handleItem() {
if (isProcessing.value)
return
const payload = pick()
if (!payload)
return
isProcessing.value = true
for (const handler of options.handlers) {
emit('processing', payload, handler)
try {
const result = await handler({ data: payload, itemsToBeProcessed: () => queue.value.length, emit: emitHandlerEvent })
emit('processed', payload, result, handler)
}
catch (err) {
emit('error', payload, err as Error, handler)
continue
}
}
isProcessing.value = false
emit('done', payload)
// Process next item if any
if (queue.value.length > 0)
handleItem()
}
on('add', handleItem)
on('done', handleItem)
return {
add,
on,
onHandlerEvent,
queue,
}
}
export type UseQueueReturn<T> = ReturnType<typeof useQueue<T>>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
const router = createRouter({ routes, history: createWebHashHistory() })
createApp(App)
.use(router)
.mount('#app')
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { AssistantMessage, Message } from '@xsai/shared-chat'
import type {
ElevenLabsWebSocketEventAudioOutput,
ElevenLabsWebSocketEventCloseConnection,
ElevenLabsWebSocketEventFinalOutput,
ElevenLabsWebSocketEventInitializeConnection,
ElevenLabsWebSocketEventSendText,
} from '../types/elevenlabs'
import { useLocalStorage, useWebSocket } from '@vueuse/core'
import { streamText } from '@xsai/stream-text'
import { computed, ref, toRaw, watch } from 'vue'
import { useQueue } from '../composables/queue'
const baseUrl = useLocalStorage('settings/llm/baseUrl', 'https://openrouter.ai/api/v1/')
const apiKey = useLocalStorage('settings/llm/apiKey', '')
const model = useLocalStorage('settings/llm/model', 'openai/gpt-4o-mini')
const sendingMessage = ref('')
const messages = ref<Message[]>([])
const streamingMessage = ref<AssistantMessage>({ role: 'assistant', content: '' })
const audioContext = ref<AudioContext>()
const voiceId = useLocalStorage('settings/voiceId', 'lNxY9WuCBCZCISASyJ55')
const voiceApiKey = useLocalStorage('settings/voiceApiKey', '')
const voiceWsUrl = computed(() => `wss://api.elevenlabs.io/v1/text-to-speech/${voiceId.value}/stream-input`)
const { send, data } = useWebSocket<string>(voiceWsUrl)
function sendPayload<T extends
| ElevenLabsWebSocketEventInitializeConnection
| ElevenLabsWebSocketEventSendText
| ElevenLabsWebSocketEventCloseConnection,
>(payload: T) {
send(JSON.stringify(payload))
}
async function handleChatSendMessage() {
if (!audioContext.value) {
audioContext.value = new AudioContext()
}
streamingMessage.value = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: sendingMessage.value })
messages.value.push(streamingMessage.value)
const response = await streamText({
baseURL: baseUrl.value,
apiKey: apiKey.value,
model: model.value,
messages: messages.value.slice(0, messages.value.length - 1).map(msg => toRaw(msg)),
})
for await (const chunk of response.chunkStream) {
const text = chunk.choices[0].delta.content || ''
if (text !== '') {
sendPayload({
'xi-api-key': voiceApiKey.value,
text,
})
}
streamingMessage.value.content += text
}
sendPayload({
'xi-api-key': voiceApiKey.value,
'text': '',
})
}
const audioQueue = useQueue<{ audioBuffer: AudioBuffer }>({
handlers: [
(ctx) => {
return new Promise((resolve) => {
// Create an AudioBufferSourceNode
const source = audioContext.value!.createBufferSource()
source.buffer = ctx.data.audioBuffer
// Connect the source to the AudioContext's destination (the speakers)
source.connect(audioContext.value!.destination)
// Start playing the audio
source.start(0)
source.onended = () => {
resolve()
}
})
},
],
})
watch(data, (data) => {
if (!data)
return
const parsedData = JSON.parse(data) as ElevenLabsWebSocketEventAudioOutput | ElevenLabsWebSocketEventFinalOutput
if (!parsedData)
return
if (!('audio' in parsedData))
return
if (parsedData.audio == null)
return
if (!audioContext.value)
audioContext.value = new AudioContext()
const audioBase64 = parsedData.audio
// Convert base64 to array buffer
const binaryString = atob(audioBase64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
// Decode the audio data and play it
audioContext.value.decodeAudioData(bytes.buffer, (buffer) => {
audioQueue.add({ audioBuffer: buffer })
})
})
</script>
<template>
<div flex flex-col gap-2>
<!-- <h2 text-xl>
Storage
</h2> -->
<div flex="~ col" gap-2>
<div flex flex-col gap-2>
<div>
<span text-neutral-500 dark:text-neutral-400>LLM</span>
</div>
<div grid grid-cols-2 gap-2>
<label flex items-center gap-2>
<span text-nowrap>
Base URL
</span>
<input
v-model="baseUrl"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
<label flex items-center gap-2>
<span text-nowrap>
API Key
</span>
<input
v-model="apiKey"
type="password"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
<label flex items-center gap-2>
<span text-nowrap>
Model
</span>
<input
v-model="model"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
</div>
<div>
<span text-neutral-500 dark:text-neutral-400>ElevenLabs</span>
</div>
<div grid grid-cols-2 gap-2>
<label flex items-center gap-2>
<span text-nowrap>
Voice ID
</span>
<input
v-model="voiceId"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
<label flex items-center gap-2>
<span text-nowrap>
ElevenLabs API Key
</span>
<input
v-model="voiceApiKey"
type="password"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
>
</label>
</div>
</div>
<div>
<textarea
v-model="sendingMessage"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out" text="disabled:neutral-400 dark:disabled:neutral-600"
cursor="disabled:not-allowed" w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none shadow="sm"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
/>
</div>
<button rounded-lg bg="blue-100 dark:blue-900" px-4 py-2 @click="handleChatSendMessage">
Send
</button>
<div>
<div v-for="(message, index) of messages" :key="index">
<div v-if="message.role === 'user'">
<span>
{{ message.content }}
</span>
</div>
<div v-if="message.role === 'assistant'">
<span>
{{ message.content }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
+254
View File
@@ -0,0 +1,254 @@
export interface ChunkLengthScheduleOptions {
/**
* This is an advanced setting that most users shouldnt need to use.
* It relates to our generation schedule.
*
* Our WebSocket service incorporates a buffer system designed to
* optimize the Time To First Byte (TTFB) while maintaining high-quality
* streaming.
*
* All text sent to the WebSocket endpoint is added to this buffer
* and only when that buffer reaches a certain size is an audio
* generation attempted. This is because our model provides higher
* quality audio when the model has longer inputs, and can deduce more
* context about how the text should be delivered.
*
* The buffer ensures smooth audio data delivery and is automatically
* emptied with a final audio generation either when the stream is
* closed, or upon sending a flush command. We have advanced settings
* for changing the chunk schedule, which can improve latency at the
* cost of quality by generating audio more frequently with smaller
* text inputs.
*
* The chunk_length_schedule determines the minimum amount of text that
* needs to be sent and present in our buffer before audio starts being
* generated. This is to maximise the amount of context available to
* the model to improve audio quality, whilst balancing latency of the
* returned audio chunks.
*
* The default value for chunk_length_schedule is: [120, 160, 250, 290].
*
* This means that the first chunk of audio will not be generated until
* you send text that totals at least 120 characters long. The next
* chunk of audio will only be generated once a further 160 characters
* have been sent. The third audio chunk will be generated after the
* next 250 characters. Then the fourth, and beyond, will be generated
* in sets of at least 290 characters.
*
* Customize this array to suit your needs. If you want to generate
* audio more frequently to optimise latency, you can reduce the values
* in the array. Note that setting the values too low may result in
* lower quality audio. Please test and adjust as needed.
*
* Each item should be in the range 50-500.
*/
chunk_length_schedule?: number[]
}
export interface PronunciationDictionaryLocatorOptions {
/**
* Optional list of pronunciation dictionary locators.
*
* If provided, these dictionaries will be used to modify pronunciation
* of matching text. Must only be provided in the first message.
*
* Note: Pronunciation dictionary matches will only be respected
* within a provided chunk.
*/
pronunciation_dictionary_locators?: Array<{
dictionary_id: string
version_id: string
}>
}
export interface VoiceSettings {
/**
* Defines the stability for voice settings.
*
* @default 0.5
*/
stability?: number
/**
* Defines the similarity boost for voice settings.
*
* @default 0.75
*/
similarity_boost?: number
/**
* Defines the style for voice settings. This parameter is
* available on V2+ models.
*
* @default 0
*/
style?: number
/**
* Defines the use speaker boost for voice settings. This parameter is
* available on V2+ models.
*
* @default true
*/
use_speaker_boost?: boolean
/**
* Controls the speed of the generated speech. Values range from 0.7
* to 1.2, with 1.0 being the default speed.
*
* @default 1
*/
speed?: number
}
export interface ElevenLabsWebSocketEventInitializeConnection {
/**
* The text to be sent to the API for audio generation. Should always
* end with a single space string.
*/
'text': string
/**
* The voice settings field can be provided in the first
* InitializeConnection message and then must either be
* not provided or not changed.
*/
'voice_settings'?: VoiceSettings
/**
* The generator config field can be provided in the first
* InitializeConnection message and then must either be not provided or
* not changed.
*/
'generator_config'?: ChunkLengthScheduleOptions & PronunciationDictionaryLocatorOptions
/**
* Your ElevenLabs API key. This can only be included in the first
* message and is not needed if present in the header.
*/
'xi-api-key'?: string
/**
* Your authorization bearer token. This can only be included in the
* first message and is not needed if present in the header.
*/
'authorization'?: string
}
export interface ElevenLabsWebSocketEventSendText {
/**
* The text to be sent to the API for audio generation. Should always
* end with a single space string.
*/
text: string
/**
* This is an advanced setting that most users shouldnt need to use.
* It relates to our generation schedule.
*
* Use this to attempt to immediately trigger the generation of audio,
* overriding the chunk_length_schedule. Unlike flush,
* try_trigger_generation will only generate audio if our buffer
* contains more than a minimum threshold of characters, this is to
* ensure a higher quality response from our model.
*
* Note that overriding the chunk schedule to generate small amounts of
* text may result in lower quality audio, therefore, only use this
* parameter if you really need text to be processed immediately. We
* generally recommend keeping the default value of false and adjusting
* the chunk_length_schedule in the generation_config instead.
*/
try_trigger_generation?: boolean
/**
* The voice settings field can be provided in the first
* InitializeConnection message and then must either be not
* provided or not changed.
*/
voice_settings?: VoiceSettings
/**
* The generator config field can be provided in the first
* InitializeConnection message and then must either be not provided or
* not changed.
*/
generator_config?: ChunkLengthScheduleOptions
/**
* Flush forces the generation of audio. Set this value to true when you
* have finished sending text, but want to keep the websocket connection
* open.
*
* This is useful when you want to ensure that the last chunk of audio is
* generated even when the length of text sent is smaller than the value
* set in chunk_length_schedule (e.g. 120 or 50).
*
* @default false
*/
flush?: boolean
}
export interface ElevenLabsWebSocketEventCloseConnection {
/**
* End the stream with an empty string
*/
text: string
}
export interface ElevenLabsWebSocketEventAudioOutput {
/**
* A generated partial audio chunk, encoded using the selected output_format,
* by default this is MP3 encoded as a base64 string.
*/
audio: string
/**
* Alignment information for the generated audio given the input normalized text sequence.
*/
normalizedAlignment?: {
/**
* A list of starting times (in milliseconds) for each character in the
* normalized text as it corresponds to the audio. For instance, the
* character H starts at time 0 ms in the audio. Note these times are
* relative to the returned chunk from the model, and not the full audio
* response.
*/
char_start_times_ms?: number[]
/**
* A list of durations (in milliseconds) for each character in the
* normalized text as it corresponds to the audio. For instance, the
* character H lasts for 3 ms in the audio. Note these times are
* relative to the returned chunk from the model, and not the full
* audio response.
*/
chars_durations_ms?: number[]
/**
* A list of characters in the normalized text sequence. For instance, the
* first character is H. Note that this list may contain spaces,
* punctuation, and other special characters. The length of this list should
* be the same as the lengths of char_start_times_ms and chars_durations_ms.
*/
chars?: string[]
}
/**
* Alignment information for the generated audio given the input text sequence.
*/
alignment?: {
/**
* A list of starting times (in milliseconds) for each character in the text
* as it corresponds to the audio. For instance, the character H starts at
* time 0 ms in the audio. Note these times are relative to the returned
* chunk from the model, and not the full audio response.
*/
char_start_times_ms?: number[]
/**
* A list of durations (in milliseconds) for each character in the text as
* it corresponds to the audio. For instance, the character H lasts for 3
* ms in the audio. Note these times are relative to the returned chunk from
* the model, and not the full audio response.
*/
chars_durations_ms?: number[]
/**
* A list of characters in the text sequence. For instance, the first character
* is H. Note that this list may contain spaces, punctuation, and other
* special characters. The length of this list should be the same as the
* lengths of char_start_times_ms and chars_durations_ms.
*/
chars?: string[]
}
}
export interface ElevenLabsWebSocketEventFinalOutput {
/**
* Indicates if the generation is complete. If set to True, audio will be
* null.
*/
isFinal?: boolean
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable",
"DOM.AsyncIterable",
"WebWorker"
],
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@proj-airi/duckdb-wasm/*": [
"../duckdb-wasm/src/*"
],
"@proj-airi/duckdb-wasm": [
"../duckdb-wasm/src/index.ts"
]
},
"types": [
"vite/client",
"@vitest/browser/providers/playwright",
"unplugin-vue-router/client"
],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.mts",
"playground/**/*.ts",
"playground/**/*.d.ts",
"playground/**/*.mts",
"playground/**/*.vue"
]
}
+33
View File
@@ -0,0 +1,33 @@
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetWebFonts,
presetWind3,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
export default defineConfig({
presets: [
presetWind3(),
presetAttributify(),
presetTypography(),
presetWebFonts({
fonts: {
sans: 'DM Sans',
serif: 'DM Serif Display',
mono: 'DM Mono',
},
}),
presetIcons({
scale: 1.2,
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
})
+19
View File
@@ -0,0 +1,19 @@
import { resolve } from 'node:path'
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({
plugins: [
// https://github.com/posva/unplugin-vue-router
VueRouter({
extensions: ['.vue', '.md'],
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
}),
Vue(),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
],
})
@@ -1,15 +1,20 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
RadioCardDetailManySelect,
RadioCardSimple,
Skeleton,
TestDummyMarker,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
@@ -23,56 +28,155 @@ const {
const {
activeSpeechProvider,
activeSpeechModel,
voiceId,
activeSpeechVoice,
pitch,
rate,
isLoadingSpeechProviderVoices,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
modelSearchQuery,
speechProviderError,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const router = useRouter()
const ssmlExample = ref(`<speak>
Hello, my name is <voice name="${voiceId.value || 'Default'}">
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
AI Assistant
</prosody>
</voice>
</speak>`)
const voiceId = ref('')
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
const ssmlText = ref('')
const isGenerating = ref(false)
const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
watch(activeSpeechProvider, async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
function updateCustomVoiceName(value: string) {
voiceId.value = value
updateSSMLExample()
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
if (!activeSpeechModel.value) {
console.error('No model selected')
return
}
if (!activeSpeechVoice.value) {
console.error('No voice selected')
return
}
const provider = providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, activeSpeechVoice.value)
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
function updateSSMLExample() {
ssmlExample.value = `<speak>
Hello, my name is <voice name="${voiceId.value || 'Default'}">
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
AI Assistant
</prosody>
</voice>
</speak>`
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Add this function to handle voice selection from the preview player
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function handleVoiceSelection(value: string) {
voiceId.value = value
updateSSMLExample()
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === value)
}
watch(voiceId, updateSSMLExample)
function updateCustomVoiceName(value: string) {
activeSpeechVoice.value = {
id: value,
name: value,
description: value,
previewURL: value,
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'male',
}
}
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
watch(voiceId, (newVoice) => {
const foundVoice = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === newVoice)
if (foundVoice) {
activeSpeechVoice.value = foundVoice
}
else {
updateCustomVoiceName(newVoice)
}
})
</script>
<template>
@@ -92,65 +196,133 @@ watch(voiceId, updateSSMLExample)
</div>
</h1>
</div>
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="w-full md:w-[40%]">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableAudioSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableAudioSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
<div>
<!-- Model selection section -->
<div v-if="activeSpeechProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<div
v-else-if="activeProviderModelError"
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error') }}</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ activeProviderModelError }}</span>
</div>
</div>
<!-- No models available -->
<div
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
class="flex items-center gap-3 border border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models')
}}</span>
<span class="text-sm text-amber-600 dark:text-amber-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}</span>
</div>
</div>
<!-- Using the new RadioCardDetailManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardDetailManySelect
v-model="activeSpeechModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
</div>
</div>
</div>
<!-- Loading state -->
<TransitionGroup name="fade-slide-in-out">
<!-- Loading state -->
<div v-if="isLoadingSpeechProviderVoices">
<div class="flex flex-col gap-4">
<Skeleton class="w-full rounded-lg p-2.5 text-sm">
@@ -182,13 +354,13 @@ watch(voiceId, updateSSMLExample)
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="voiceId"
:selected-voice-id="activeSpeechVoice?.id"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
@@ -228,40 +400,14 @@ watch(voiceId, updateSSMLExample)
</div>
</div>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<!-- Voice parameters -->
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch Adjustment (%)"
description="Tune the pitch of the speech"
:min="-100" :max="100" :step="0.1"
/>
<FieldRange
v-model="rate"
label="Speech Rate"
description="Adjust the speed of the speech"
:min="0.5" :max="2" :step="0.01"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
<!-- SSML Support -->
<FieldCheckbox
@@ -270,62 +416,117 @@ watch(voiceId, updateSSMLExample)
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
</div>
</TransitionGroup>
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch Adjustment (%)"
description="Tune the pitch of the speech"
:min="-100" :max="100" :step="0.1"
/>
<FieldRange
v-model="rate"
label="Speech Rate"
description="Adjust the speed of the speech"
:min="0.5" :max="2" :step="0.01"
/>
<!-- SSML Support -->
<FieldCheckbox
v-model="ssmlEnabled"
label="Enable SSML"
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
@@ -344,24 +545,3 @@ meta:
stageTransition:
name: slide
</route>
<style scoped>
.fade-slide-in-out-enter-active,
.fade-slide-in-out-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-slide-in-out-enter-from,
.fade-slide-in-out-leave-to {
opacity: 0;
}
.fade-slide-in-out-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-slide-in-out-leave-from,
.fade-slide-in-out-enter-to {
opacity: 1;
}
</style>
@@ -0,0 +1,420 @@
<script setup lang="ts">
import type { UnMicrosoftOptions } from '@xsai-ext/providers-local'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
TestDummyMarker,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { useDebounceFn } from '@vueuse/core'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { providers } = storeToRefs(providersStore)
const { availableVoices } = storeToRefs(speechStore)
// For playground
const testText = ref('Hello! This is a test of the Microsoft Speech synthesis.')
const isGenerating = ref(false)
const audioUrl = ref('')
const errorMessage = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const useSSML = ref(false)
const ssmlText = ref('<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">\n <voice name="en-US-AvaMultilingualNeural">\n <prosody rate="+10.00%" pitch="+10.00%">\n Hello! This is a test of the Microsoft Speech synthesis with SSML.\n </prosody>\n </voice>\n</speak>')
// Get provider metadata
const providerId = 'microsoft-speech'
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || providerMetadata.value?.defaultOptions?.region as string | undefined || 'eastasia',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].region = value
},
})
const baseUrl = computed({
get: () => providers.value[providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].baseUrl = value
},
})
// Voice settings as individual computed properties
const pitch = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.pitch ?? 0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.pitch = value
},
})
const speed = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.speed ?? 1.0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.speed = value
},
})
const volume = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.volume ?? 0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.volume = value
},
})
// Speech settings
const selectedLanguage = ref(speechStore.selectedLanguage)
const selectedVoice = ref('')
const availableVoicesForLanguage = computed(() => {
if (availableVoices.value[providerId] == null) {
return []
}
return availableVoices.value[providerId].filter(voice => voice.languages.filter(language => language.code === selectedLanguage.value).length > 0)
})
onMounted(() => {
providersStore.initializeProvider(providerId)
// Initialize refs with current values
apiKey.value = providers.value[providerId]?.apiKey as string | undefined || ''
baseUrl.value = providers.value[providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || ''
// Initialize voice settings refs
if (providers.value[providerId]?.voiceSettings) {
pitch.value = (providers.value[providerId].voiceSettings as any)?.pitch ?? 0
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
volume.value = (providers.value[providerId].voiceSettings as any)?.volume ?? 0
}
// Load voices if provider is configured
if (providersStore.configuredProviders[providerId]) {
speechStore.loadVoicesForProvider(providerId)
}
})
const debouncedUpdate = useDebounceFn(() => {
providers.value[providerId] = {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || '',
voiceSettings: {
pitch: pitch.value,
speed: speed.value,
volume: volume.value,
},
}
speechStore.loadVoicesForProvider(providerId)
}, 1000)
// Watch all settings and update the provider configuration
watch([apiKey, baseUrl, region], debouncedUpdate)
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const voice = availableVoicesForLanguage.value.find(voice => voice.name === selectedVoice.value)
if (!voice) {
throw new Error('Please select a voice')
}
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, voice)
const response = await generateSpeech({
...provider.speech('v1', {
region: region.value,
disableSsml: true, // Disable auto SSML conversion since we're handling it ourselves
}),
input,
voice: voice.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function handleResetVoiceSettings() {
providers.value[providerId] = {
...(providerMetadata.value?.defaultOptions as any),
}
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName" :provider-icon="providerMetadata?.icon"
:on-back="() => router.back()"
>
<div flex="~ col md:row gap-6">
<ProviderSettingsContainer class="w-full md:w-[40%]">
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetVoiceSettings"
>
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName" placeholder="sk-" />
<FieldInput
v-model="region"
label="Region"
description="Speech Service region"
placeholder="eastasia"
required
type="text"
/>
</ProviderBasicSettings>
<div flex="~ col gap-6">
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.providers.common.section.voice.title') }}
</h2>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Adjust the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<ProviderBaseUrlInput
v-model="baseUrl"
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''" required
/>
</ProviderAdvancedSettings>
</ProviderSettingsContainer>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ col gap-6">
<label grid="~ cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ t('settings.pages.providers.provider.elevenlabs.playground.fields.field.language.label') }}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.elevenlabs.playground.fields.field.language.description') }}
</div>
</div>
<select
v-model="selectedLanguage"
border="neutral-300 dark:neutral-800 solid 2 focus:neutral-400 dark:focus:neutral-600"
transition="border duration-250 ease-in-out" w-full rounded-lg px-2 py-1 text-nowrap text-sm
outline-none
>
<option v-for="language in speechStore.availableLanguages" :key="language" :value="language">
{{ language }}
</option>
</select>
</label>
<label grid="~ cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ t('settings.pages.providers.provider.elevenlabs.playground.fields.field.voice.label') }}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.elevenlabs.playground.fields.field.voice.description') }}
</div>
</div>
<select
v-model="selectedVoice"
border="neutral-300 dark:neutral-800 solid 2 focus:neutral-400 dark:focus:neutral-600"
transition="border duration-250 ease-in-out" w-full rounded-lg px-2 py-1 text-nowrap text-sm
outline-none
>
<option value="">
Select a voice
</option>
<option v-for="voice in availableVoicesForLanguage" :key="voice.id" :value="voice.name">
{{ voice.name }}
</option>
</select>
</label>
</div>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKey || !selectedVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKey || !selectedVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<div v-if="!apiKey" class="mt-2 text-sm text-red-500">
{{ t('settings.pages.providers.provider.elevenlabs.playground.validation.error-missing-api-key') }}
</div>
<div v-if="!selectedVoice" class="mt-2 text-sm text-red-500">
Please select a voice
</div>
<div v-if="errorMessage" class="mt-2 text-sm text-red-500">
{{ errorMessage }}
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
</div>
</ProviderSettingsLayout>
</template>
+1 -1
View File
@@ -20,7 +20,7 @@ const { top, right, bottom, left } = useScreenSafeArea()
<div mb-4 w-full gap-2 class="px-0 py-1 md:px-3 md:py-3">
<HeaderLink />
</div>
<div class="px-3 py-2 md:px-5 md:py-5" flex="~ col gap-4" mx-auto max-w-screen-lg>
<div class="px-3 py-2 md:px-5 md:py-5" flex="~ col gap-4" mx-auto max-w-screen-xl>
<RouterView />
</div>
</div>
@@ -1,15 +1,20 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
RadioCardDetailManySelect,
RadioCardSimple,
Skeleton,
TestDummyMarker,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRouter } from 'vue-router'
@@ -23,56 +28,155 @@ const {
const {
activeSpeechProvider,
activeSpeechModel,
voiceId,
activeSpeechVoice,
pitch,
rate,
isLoadingSpeechProviderVoices,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
modelSearchQuery,
speechProviderError,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const router = useRouter()
const ssmlExample = ref(`<speak>
Hello, my name is <voice name="${voiceId.value || 'Default'}">
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
AI Assistant
</prosody>
</voice>
</speak>`)
const voiceId = ref('')
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
const ssmlText = ref('')
const isGenerating = ref(false)
const audioUrl = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const errorMessage = ref('')
onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
watch(activeSpeechProvider, async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
await providersStore.loadModelsForConfiguredProviders()
})
function updateCustomVoiceName(value: string) {
voiceId.value = value
updateSSMLExample()
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
if (!activeSpeechModel.value) {
console.error('No model selected')
return
}
if (!activeSpeechVoice.value) {
console.error('No voice selected')
return
}
const provider = providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
isGenerating.value = true
errorMessage.value = ''
try {
// Stop any currently playing audio
if (audioUrl.value) {
stopTestAudio()
}
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, activeSpeechVoice.value)
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Convert the response to a blob and create an object URL
audioUrl.value = URL.createObjectURL(new Blob([response]))
// Play the audio
setTimeout(() => {
if (audioPlayer.value) {
audioPlayer.value.play()
}
}, 100)
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
}
finally {
isGenerating.value = false
}
}
function updateSSMLExample() {
ssmlExample.value = `<speak>
Hello, my name is <voice name="${voiceId.value || 'Default'}">
<prosody pitch="+${pitch.value || 0}%" rate="${rate.value || 1}">
AI Assistant
</prosody>
</voice>
</speak>`
// Function to stop audio playback
function stopTestAudio() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
}
// Clean up the object URL to prevent memory leaks
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
audioUrl.value = ''
}
}
// Add this function to handle voice selection from the preview player
// Clean up when component is unmounted
onUnmounted(() => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value)
}
})
function handleVoiceSelection(value: string) {
voiceId.value = value
updateSSMLExample()
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === value)
}
watch(voiceId, updateSSMLExample)
function updateCustomVoiceName(value: string) {
activeSpeechVoice.value = {
id: value,
name: value,
description: value,
previewURL: value,
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'male',
}
}
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
watch(voiceId, (newVoice) => {
const foundVoice = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === newVoice)
if (foundVoice) {
activeSpeechVoice.value = foundVoice
}
else {
updateCustomVoiceName(newVoice)
}
})
</script>
<template>
@@ -92,65 +196,133 @@ watch(voiceId, updateSSMLExample)
</div>
</h1>
</div>
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="w-full md:w-[40%]">
<div>
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-500">
<span>{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.description') }}</span>
</div>
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableAudioSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
</div>
</div>
</div>
<div max-w-full>
<fieldset
v-if="availableProviders.length > 0" flex="~ row gap-4" :style="{ 'scrollbar-width': 'none' }"
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableAudioSpeechProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
<div v-else>
<RouterLink
class="flex items-center gap-3 rounded-lg p-4" border="2 dashed neutral-200 dark:neutral-800"
bg="neutral-50 dark:neutral-800" transition="colors duration-200 ease-in-out" to="/settings/providers"
>
<div i-solar:warning-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">No Speech Providers Configured</span>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Click here to set up your speech
providers</span>
<div>
<!-- Model selection section -->
<div v-if="activeSpeechProvider && supportsModelListing">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg md:text-2xl">
{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.title') }}
</h2>
<div text="neutral-400 dark:neutral-400">
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }}</span>
</div>
</div>
<div i-solar:arrow-right-line-duotone class="ml-auto text-xl text-neutral-400 dark:text-neutral-500" />
</RouterLink>
<!-- Loading state -->
<div v-if="isLoadingActiveProviderModels" class="flex items-center justify-center py-4">
<div class="mr-2 animate-spin">
<div i-solar:spinner-line-duotone text-xl />
</div>
<span>{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.loading') }}</span>
</div>
<!-- Error state -->
<div
v-else-if="activeProviderModelError"
class="flex items-center gap-3 border border-red-200 rounded-lg bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<div i-solar:close-circle-line-duotone class="text-2xl text-red-500 dark:text-red-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.error') }}</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ activeProviderModelError }}</span>
</div>
</div>
<!-- No models available -->
<div
v-else-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
class="flex items-center gap-3 border border-amber-200 rounded-lg bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models')
}}</span>
<span class="text-sm text-amber-600 dark:text-amber-400">{{
t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_models_description') }}</span>
</div>
</div>
<!-- Using the new RadioCardDetailManySelect component -->
<template v-else-if="providerModels.length > 0">
<RadioCardDetailManySelect
v-model="activeSpeechModel"
v-model:search-query="modelSearchQuery"
:items="providerModels"
:searchable="true"
:search-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_placeholder')"
:search-no-results-title="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results')"
:search-no-results-description="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.no_search_results_description', { query: modelSearchQuery })"
:search-results-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.search_results', { count: '{count}', total: '{total}' })"
:custom-input-placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.custom_model_placeholder')"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
@update:custom-value="updateCustomModelName"
/>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
<!-- Voice Configuration Section -->
<div v-if="activeSpeechProvider">
<div flex="~ col gap-4">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Voice Configuration
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Customize how your AI assistant speaks</span>
</div>
</div>
</div>
<!-- Loading state -->
<TransitionGroup name="fade-slide-in-out">
<!-- Loading state -->
<div v-if="isLoadingSpeechProviderVoices">
<div class="flex flex-col gap-4">
<Skeleton class="w-full rounded-lg p-2.5 text-sm">
@@ -182,13 +354,13 @@ watch(voiceId, updateSSMLExample)
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="voiceId"
:selected-voice-id="activeSpeechVoice?.id"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
@@ -228,40 +400,14 @@ watch(voiceId, updateSSMLExample)
</div>
</div>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<!-- Voice parameters -->
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch Adjustment (%)"
description="Tune the pitch of the speech"
:min="-100" :max="100" :step="0.1"
/>
<FieldRange
v-model="rate"
label="Speech Rate"
description="Adjust the speed of the speech"
:min="0.5" :max="2" :step="0.01"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
<!-- SSML Support -->
<FieldCheckbox
@@ -270,62 +416,117 @@ watch(voiceId, updateSSMLExample)
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
</div>
</TransitionGroup>
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
<!-- Manual voice input when no voices are available -->
<div
v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0"
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
placeholder="Enter voice name (e.g., 'Rachel', 'Josh')"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Tune the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
</div>
</div>
</div>
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<h2 class="mb-4 text-lg text-neutral-500 md:text-2xl dark:text-neutral-400" w-full>
<div class="inline-flex items-center gap-4">
<TestDummyMarker />
<div>
{{ t('settings.pages.providers.provider.elevenlabs.playground.title') }}
</div>
</div>
</h2>
<div flex="~ col gap-4">
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<!-- Model selection for ElevenLabs -->
<div v-if="activeSpeechProvider === 'elevenlabs'">
<label class="mb-1 block text-sm font-medium">
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="eleven_monolingual_v1">
Monolingual v1
</option>
<option value="eleven_multilingual_v1">
Multilingual v1
</option>
<option value="eleven_multilingual_v2">
Multilingual v2
</option>
</select>
</div>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch Adjustment (%)"
description="Tune the pitch of the speech"
:min="-100" :max="100" :step="0.1"
/>
<FieldRange
v-model="rate"
label="Speech Rate"
description="Adjust the speed of the speech"
:min="0.5" :max="2" :step="0.01"
/>
<!-- SSML Support -->
<FieldCheckbox
v-model="ssmlEnabled"
label="Enable SSML"
description="Enable Speech Synthesis Markup Language for more control over speech output"
/>
<div flex="~ row" gap-4>
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !activeSpeechVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>{{ isGenerating ? t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.generating') : t('settings.pages.providers.provider.elevenlabs.playground.buttons.button.test-voice.label') }}</span>
</div>
</button>
<button
v-if="audioUrl" border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="stopTestAudio"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Stop</span>
</div>
</button>
</div>
<audio v-if="audioUrl" ref="audioPlayer" :src="audioUrl" controls class="mt-2 w-full" />
</div>
</div>
</div>
@@ -344,24 +545,3 @@ meta:
stageTransition:
name: slide
</route>
<style scoped>
.fade-slide-in-out-enter-active,
.fade-slide-in-out-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-slide-in-out-enter-from,
.fade-slide-in-out-leave-to {
opacity: 0;
}
.fade-slide-in-out-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-slide-in-out-leave-from,
.fade-slide-in-out-enter-to {
opacity: 1;
}
</style>
@@ -3,7 +3,9 @@ import type { UnMicrosoftOptions } from '@xsai-ext/providers-local'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import {
FieldCheckbox,
FieldInput,
FieldRange,
ProviderAdvancedSettings,
ProviderApiKeyInput,
ProviderBaseUrlInput,
@@ -33,6 +35,8 @@ const isGenerating = ref(false)
const audioUrl = ref('')
const errorMessage = ref('')
const audioPlayer = ref<HTMLAudioElement | null>(null)
const useSSML = ref(false)
const ssmlText = ref('<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">\n <voice name="en-US-AvaMultilingualNeural">\n <prosody rate="+10.00%" pitch="+10.00%">\n Hello! This is a test of the Microsoft Speech synthesis with SSML.\n </prosody>\n </voice>\n</speak>')
// Get provider metadata
const providerId = 'microsoft-speech'
@@ -68,6 +72,46 @@ const baseUrl = computed({
},
})
// Voice settings as individual computed properties
const pitch = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.pitch ?? 0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.pitch = value
},
})
const speed = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.speed ?? 1.0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.speed = value
},
})
const volume = computed({
get: () => (providers.value[providerId]?.voiceSettings as any)?.volume ?? 0,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
if (!providers.value[providerId].voiceSettings)
providers.value[providerId].voiceSettings = {}
const voiceSettings = providers.value[providerId].voiceSettings as any
voiceSettings.volume = value
},
})
// Speech settings
const selectedLanguage = ref(speechStore.selectedLanguage)
const selectedVoice = ref('')
@@ -86,6 +130,13 @@ onMounted(() => {
apiKey.value = providers.value[providerId]?.apiKey as string | undefined || ''
baseUrl.value = providers.value[providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || ''
// Initialize voice settings refs
if (providers.value[providerId]?.voiceSettings) {
pitch.value = (providers.value[providerId].voiceSettings as any)?.pitch ?? 0
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
volume.value = (providers.value[providerId].voiceSettings as any)?.volume ?? 0
}
// Load voices if provider is configured
if (providersStore.configuredProviders[providerId]) {
speechStore.loadVoicesForProvider(providerId)
@@ -97,17 +148,25 @@ const debouncedUpdate = useDebounceFn(() => {
...providers.value[providerId],
apiKey: apiKey.value,
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || '',
voiceSettings: {
pitch: pitch.value,
speed: speed.value,
volume: volume.value,
},
}
speechStore.loadVoicesForProvider(providerId)
}, 1000)
// Watch all settings and update the provider configuration
watch([apiKey, baseUrl], debouncedUpdate)
watch([apiKey, baseUrl, region], debouncedUpdate)
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim())
if (!testText.value.trim() && !useSSML.value)
return
if (useSSML.value && !ssmlText.value.trim())
return
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
@@ -126,11 +185,21 @@ async function generateTestSpeech() {
}
const voice = availableVoicesForLanguage.value.find(voice => voice.name === selectedVoice.value)
if (!voice) {
throw new Error('Please select a voice')
}
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, voice)
const response = await generateSpeech({
...provider.speech('v1', { region: region.value, gender: voice?.gender }),
input: testText.value,
voice: voice!.id,
...provider.speech('v1', {
region: region.value,
disableSsml: true, // Disable auto SSML conversion since we're handling it ourselves
}),
input,
voice: voice.id,
})
// Convert the response to a blob and create an object URL
@@ -207,6 +276,15 @@ function handleResetVoiceSettings() {
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.providers.common.section.voice.title') }}
</h2>
<div flex="~ col gap-4">
<FieldRange
v-model="pitch"
label="Pitch"
description="Adjust the pitch of the voice"
:min="-100" :max="100" :step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
@@ -228,14 +306,33 @@ function handleResetVoiceSettings() {
</div>
</h2>
<div flex="~ col gap-4">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
<FieldCheckbox
v-model="useSSML"
label="Use Custom SSML"
description="Enable to input raw SSML instead of plain text"
/>
<template v-if="!useSSML">
<textarea
v-model="testText"
:placeholder="t('settings.pages.providers.provider.elevenlabs.playground.fields.field.input.placeholder')"
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-24 w-full rounded-lg px-3 py-2 text-sm outline-none
/>
</template>
<template v-else>
<textarea
v-model="ssmlText"
placeholder="Enter SSML text..."
border="neutral-100 dark:neutral-800 solid 2 focus:neutral-200 dark:focus:neutral-700"
transition="all duration-250 ease-in-out"
bg="neutral-100 dark:neutral-800 focus:neutral-50 dark:focus:neutral-900"
h-48 w-full rounded-lg px-3 py-2 text-sm font-mono outline-none
/>
</template>
<div flex="~ col gap-6">
<label grid="~ cols-2 gap-4">
<div>
@@ -286,8 +383,8 @@ function handleResetVoiceSettings() {
<button
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="isGenerating || !testText.trim() || !apiKey"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || !testText.trim() || !apiKey }"
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKey || !selectedVoice"
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !apiKey || !selectedVoice }"
bg="neutral-700 dark:neutral-300" @click="generateTestSpeech"
>
<div flex="~ row" items-center gap-2>
@@ -308,6 +405,9 @@ function handleResetVoiceSettings() {
<div v-if="!apiKey" class="mt-2 text-sm text-red-500">
{{ t('settings.pages.providers.provider.elevenlabs.playground.validation.error-missing-api-key') }}
</div>
<div v-if="!selectedVoice" class="mt-2 text-sm text-red-500">
Please select a voice
</div>
<div v-if="errorMessage" class="mt-2 text-sm text-red-500">
{{ errorMessage }}
</div>
@@ -104,7 +104,7 @@ function updateCustomValue(value: string) {
<div class="relative">
<!-- Horizontally scrollable container -->
<div
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col gap-4 overflow-x-auto pb-4"
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col max-h-[calc(100dvh-7lh)] gap-4 overflow-x-auto pb-4"
:class="[
isListExpanded ? 'grid-cols-1 md:grid-cols-2 grid-flow-row auto-cols-auto' : '',
]"
@@ -136,7 +136,7 @@ function updateCustomValue(value: string) {
bg="neutral-100 dark:[rgba(0,0,0,0.3)]"
rounded-xl
:class="[
isListExpanded ? 'fixed bottom-4 left-1/2 translate-x--1/2 z-10 w-full px-9 max-w-screen-lg' : 'mt-0 w-full rounded-lg',
isListExpanded ? 'w-full' : 'mt-4 w-full rounded-lg',
]"
>
<button
@@ -31,7 +31,7 @@ interface Voice {
interface Props {
voice: Voice
selectedVoiceId: string
selectedVoiceId?: string
currentlyPlayingId?: string
customInputPlaceholder?: string
showVisualizer?: boolean
@@ -28,7 +28,7 @@ interface Voice {
interface Props {
voices: Voice[]
selectedVoiceId: string
selectedVoiceId?: string
searchable?: boolean
searchPlaceholder?: string
searchNoResultsTitle?: string
@@ -350,7 +350,7 @@ const customVoiceName = ref('')
<div class="relative">
<!-- Horizontally scrollable container -->
<div
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col gap-4 overflow-x-auto pb-4"
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col max-h-[calc(100dvh-7lh)] gap-4 overflow-x-auto pb-4"
:class="[
isListExpanded ? 'grid-cols-1 md:grid-cols-2 grid-flow-row auto-cols-auto' : '',
]"
@@ -378,7 +378,7 @@ const customVoiceName = ref('')
bg="neutral-100 dark:[rgba(0,0,0,0.3)]"
rounded-xl
:class="[
isListExpanded ? 'fixed bottom-4 left-1/2 translate-x--1/2 z-10 w-full px-9 max-w-screen-lg' : 'mt-0 w-full rounded-lg',
isListExpanded ? 'w-full' : 'mt-4 w-full rounded-lg',
]"
>
<button
@@ -72,7 +72,7 @@ const audioQueue = useQueue<{ audioBuffer: AudioBuffer, text: string }>({
})
const speechStore = useSpeechStore()
const { voiceId, ssmlEnabled, activeSpeechProvider, activeSpeechModel } = storeToRefs(speechStore)
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice } = storeToRefs(speechStore)
async function handleSpeechGeneration(ctx: { data: string }) {
try {
@@ -81,6 +81,11 @@ async function handleSpeechGeneration(ctx: { data: string }) {
return
}
if (!activeSpeechVoice.value) {
console.warn('No active speech voice configured')
return
}
// TODO: UnElevenLabsOptions
const provider = providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
if (!provider) {
@@ -88,19 +93,16 @@ async function handleSpeechGeneration(ctx: { data: string }) {
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
const input = ssmlEnabled.value
? speechStore.generateSSML(ctx.data, activeSpeechVoice.value)
: ctx.data
const res = await generateSpeech({
...provider.speech(activeSpeechModel.value, {
// Optional: Add SSML wrapping if enabled
...(ssmlEnabled.value && {
input: speechStore.generateSSML(ctx.data),
}),
voiceSettings: {
stability: 0.4,
similarityBoost: 0.5,
},
}),
input: ctx.data,
voice: voiceId.value,
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
// Decode the ArrayBuffer into an AudioBuffer
@@ -44,23 +44,6 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
)
})
// Actions
function setActiveProvider(provider: string) {
activeProvider.value = provider
}
function setActiveModel(model: string) {
activeModel.value = model
}
function setCustomModelName(name: string) {
activeCustomModelName.value = name
}
function setModelSearchQuery(query: string) {
modelSearchQuery.value = query
}
function resetModelSelection() {
activeModel.value = ''
activeCustomModelName.value = ''
@@ -97,10 +80,6 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
filteredModels,
// Actions
setActiveProvider,
setActiveModel,
setCustomModelName,
setModelSearchQuery,
resetModelSelection,
loadModelsForProvider,
}
+65 -41
View File
@@ -2,7 +2,7 @@ import type { VoiceInfo } from '../providers'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { voiceList, voiceMap } from '../../constants/elevenlabs'
import { useProvidersStore } from '../providers'
@@ -13,8 +13,9 @@ export const useSpeechStore = defineStore('speech', () => {
// State
const activeSpeechProvider = useLocalStorage('settings/speech/active-provider', '')
const activeSpeechModel = useLocalStorage('settings/speech/active-model', 'eleven_multilingual_v2')
const voiceName = useLocalStorage('settings/speech/voice-name', '')
const voiceId = useLocalStorage('settings/speech/voice-id', '')
const activeSpeechVoiceId = useLocalStorage<string>('settings/speech/voice', '')
const activeSpeechVoice = ref<VoiceInfo>()
const pitch = useLocalStorage('settings/speech/pitch', 0)
const rate = useLocalStorage('settings/speech/rate', 1)
const ssmlEnabled = useLocalStorage('settings/speech/ssml-enabled', false)
@@ -22,6 +23,7 @@ export const useSpeechStore = defineStore('speech', () => {
const speechProviderError = ref<string | null>(null)
const availableVoices = ref<Record<string, VoiceInfo[]>>({})
const selectedLanguage = useLocalStorage('settings/speech/language', 'en-US')
const modelSearchQuery = ref('')
// Computed properties
const availableSpeechProvidersMetadata = computed(() => {
@@ -31,6 +33,36 @@ export const useSpeechStore = defineStore('speech', () => {
.map(id => providersStore.getProviderMetadata(id))
})
// Computed properties
const supportsModelListing = computed(() => {
return providersStore.getProviderMetadata(activeSpeechProvider.value)?.capabilities.listModels !== undefined
})
const providerModels = computed(() => {
return providersStore.getModelsForProvider(activeSpeechProvider.value)
})
const isLoadingActiveProviderModels = computed(() => {
return providersStore.isLoadingModels[activeSpeechProvider.value] || false
})
const activeProviderModelError = computed(() => {
return providersStore.modelLoadError[activeSpeechProvider.value] || null
})
const filteredModels = computed(() => {
if (!modelSearchQuery.value.trim()) {
return providerModels.value
}
const query = modelSearchQuery.value.toLowerCase().trim()
return providerModels.value.filter(model =>
model.name.toLowerCase().includes(query)
|| model.id.toLowerCase().includes(query)
|| (model.description && model.description.toLowerCase().includes(query)),
)
})
const supportsSSML = computed(() => {
// Currently only ElevenLabs and some other providers support SSML
return ['elevenlabs', 'microsoft-speech', 'azure-speech', 'google'].includes(activeSpeechProvider.value)
@@ -61,13 +93,6 @@ export const useSpeechStore = defineStore('speech', () => {
return ['elevenlabs', 'microsoft-speech', 'azure-speech', 'google', 'amazon'].includes(providerId)
}
function resetVoiceSettings() {
voiceName.value = ''
pitch.value = 0
rate.value = 1
ssmlEnabled.value = false
}
async function loadVoicesForProvider(provider: string) {
if (!provider || !isSpeechProvider(provider)) {
return []
@@ -104,43 +129,36 @@ export const useSpeechStore = defineStore('speech', () => {
}
})
// Generate SSML for the current configuration
function generateSSML(text: string): string {
if (!ssmlEnabled.value) {
return text
}
// Generate SSML from plain text and voice settings
function generateSSML(text: string, voice: VoiceInfo): string {
const pitchValue = pitch.value > 0 ? `+${pitch.value}%` : `${pitch.value}%`
let ssml = '<speak>'
if (voiceName.value) {
ssml += `<voice name="${voiceName.value}">`
}
if (pitch.value !== 0 || rate.value !== 1) {
ssml += `<prosody pitch="+${pitch.value}%" rate="${rate.value}">`
}
ssml += text
if (pitch.value !== 0 || rate.value !== 1) {
ssml += '</prosody>'
}
if (voiceName.value) {
ssml += '</voice>'
}
ssml += '</speak>'
return ssml
return `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${voice.languages[0].code}">
<voice name="${voice.id}" gender="${voice.gender}">
<prosody pitch="${pitchValue}">
${text}
</prosody>
</voice>
</speak>`
}
onMounted(() => {
if (activeSpeechVoiceId.value) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
}
})
watch(activeSpeechVoice, (voice) => {
if (voice) {
activeSpeechVoiceId.value = voice.id
}
})
return {
// State
activeSpeechProvider,
activeSpeechModel,
voiceName,
voiceId,
activeSpeechVoice,
pitch,
rate,
ssmlEnabled,
@@ -148,14 +166,20 @@ export const useSpeechStore = defineStore('speech', () => {
isLoadingSpeechProviderVoices,
speechProviderError,
availableVoices,
modelSearchQuery,
// Computed
availableSpeechProvidersMetadata,
supportsSSML,
availableLanguages,
availableVoicesForLanguage,
supportsModelListing,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
filteredModels,
resetVoiceSettings,
// Actions
loadVoicesForProvider,
getVoicesForProvider,
generateSSML,
+21 -2
View File
@@ -35,6 +35,7 @@ import { useI18n } from 'vue-i18n'
import { createUnElevenLabs } from './fix/elevenlabs'
import { listVoices } from './fix/list-voices'
import { createUnMicrosoft } from './fix/microsoft'
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
export interface ProviderMetadata {
id: string
@@ -321,7 +322,16 @@ export const useProvidersStore = defineStore('providers', () => {
createProvider: config => createUnElevenLabs((config.apiKey as string).trim(), (config.baseUrl as string).trim()) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>,
capabilities: {
listModels: async () => {
return []
return elevenLabsModels.map((model) => {
return {
id: model.model_id,
name: model.name,
provider: 'elevenlabs',
description: model.description,
contextLength: 0,
deprecated: false,
} satisfies ModelInfo
})
},
listVoices: async (config) => {
const provider = createUnElevenLabs((config.apiKey as string).trim(), (config.baseUrl as string).trim()) as VoiceProviderWithExtraOptions<UnElevenLabsOptions>
@@ -497,7 +507,16 @@ export const useProvidersStore = defineStore('providers', () => {
createProvider: config => createUnMicrosoft((config.apiKey as string).trim(), (config.baseUrl as string).trim()) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>,
capabilities: {
listModels: async () => {
return []
return [
{
id: 'v1',
name: 'v1',
provider: 'microsoft-speech',
description: '',
contextLength: 0,
deprecated: false,
},
]
},
listVoices: async (config) => {
const provider = createUnMicrosoft((config.apiKey as string).trim(), (config.baseUrl as string).trim()) as VoiceProviderWithExtraOptions<UnMicrosoftOptions>
@@ -0,0 +1,740 @@
export const models = [
{
model_id: 'eleven_multilingual_v2',
name: 'Eleven Multilingual v2',
can_be_finetuned: true,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: true,
can_use_speaker_boost: true,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our most life-like, emotionally rich mode in 29 languages. Best for voice overs, audiobooks, post-production, or any other content creation needs.',
requires_alpha_access: false,
max_characters_request_free_user: 10000,
max_characters_request_subscribed_user: 10000,
maximum_text_length_per_request: 10000,
languages: [
{
language_id: 'en',
name: 'English',
},
{
language_id: 'ja',
name: 'Japanese',
},
{
language_id: 'zh',
name: 'Chinese',
},
{
language_id: 'de',
name: 'German',
},
{
language_id: 'hi',
name: 'Hindi',
},
{
language_id: 'fr',
name: 'French',
},
{
language_id: 'ko',
name: 'Korean',
},
{
language_id: 'pt',
name: 'Portuguese',
},
{
language_id: 'it',
name: 'Italian',
},
{
language_id: 'es',
name: 'Spanish',
},
{
language_id: 'id',
name: 'Indonesian',
},
{
language_id: 'nl',
name: 'Dutch',
},
{
language_id: 'tr',
name: 'Turkish',
},
{
language_id: 'fil',
name: 'Filipino',
},
{
language_id: 'pl',
name: 'Polish',
},
{
language_id: 'sv',
name: 'Swedish',
},
{
language_id: 'bg',
name: 'Bulgarian',
},
{
language_id: 'ro',
name: 'Romanian',
},
{
language_id: 'ar',
name: 'Arabic',
},
{
language_id: 'cs',
name: 'Czech',
},
{
language_id: 'el',
name: 'Greek',
},
{
language_id: 'fi',
name: 'Finnish',
},
{
language_id: 'hr',
name: 'Croatian',
},
{
language_id: 'ms',
name: 'Malay',
},
{
language_id: 'sk',
name: 'Slovak',
},
{
language_id: 'da',
name: 'Danish',
},
{
language_id: 'ta',
name: 'Tamil',
},
{
language_id: 'uk',
name: 'Ukrainian',
},
{
language_id: 'ru',
name: 'Russian',
},
],
model_rates: {
character_cost_multiplier: 1,
},
concurrency_group: 'standard',
},
{
model_id: 'eleven_flash_v2_5',
name: 'Eleven Flash v2.5',
can_be_finetuned: true,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our ultra low latency model in 32 languages. Ideal for conversational use cases.',
requires_alpha_access: false,
max_characters_request_free_user: 40000,
max_characters_request_subscribed_user: 40000,
maximum_text_length_per_request: 40000,
languages: [
{
language_id: 'en',
name: 'English',
},
{
language_id: 'ja',
name: 'Japanese',
},
{
language_id: 'zh',
name: 'Chinese',
},
{
language_id: 'de',
name: 'German',
},
{
language_id: 'hi',
name: 'Hindi',
},
{
language_id: 'fr',
name: 'French',
},
{
language_id: 'ko',
name: 'Korean',
},
{
language_id: 'pt',
name: 'Portuguese',
},
{
language_id: 'it',
name: 'Italian',
},
{
language_id: 'es',
name: 'Spanish',
},
{
language_id: 'ru',
name: 'Russian',
},
{
language_id: 'id',
name: 'Indonesian',
},
{
language_id: 'nl',
name: 'Dutch',
},
{
language_id: 'tr',
name: 'Turkish',
},
{
language_id: 'fil',
name: 'Filipino',
},
{
language_id: 'pl',
name: 'Polish',
},
{
language_id: 'sv',
name: 'Swedish',
},
{
language_id: 'bg',
name: 'Bulgarian',
},
{
language_id: 'ro',
name: 'Romanian',
},
{
language_id: 'ar',
name: 'Arabic',
},
{
language_id: 'cs',
name: 'Czech',
},
{
language_id: 'el',
name: 'Greek',
},
{
language_id: 'fi',
name: 'Finnish',
},
{
language_id: 'hr',
name: 'Croatian',
},
{
language_id: 'ms',
name: 'Malay',
},
{
language_id: 'sk',
name: 'Slovak',
},
{
language_id: 'da',
name: 'Danish',
},
{
language_id: 'ta',
name: 'Tamil',
},
{
language_id: 'uk',
name: 'Ukrainian',
},
{
language_id: 'hu',
name: 'Hungarian',
},
{
language_id: 'no',
name: 'Norwegian',
},
{
language_id: 'vi',
name: 'Vietnamese',
},
],
model_rates: {
character_cost_multiplier: 0.5,
},
concurrency_group: 'turbo',
},
{
model_id: 'eleven_turbo_v2_5',
name: 'Eleven Turbo v2.5',
can_be_finetuned: true,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our high quality, low latency model in 32 languages. Best for developer use cases where speed matters and you need non-English languages.',
requires_alpha_access: false,
max_characters_request_free_user: 40000,
max_characters_request_subscribed_user: 40000,
maximum_text_length_per_request: 40000,
languages: [
{
language_id: 'en',
name: 'English',
},
{
language_id: 'ja',
name: 'Japanese',
},
{
language_id: 'zh',
name: 'Chinese',
},
{
language_id: 'de',
name: 'German',
},
{
language_id: 'hi',
name: 'Hindi',
},
{
language_id: 'fr',
name: 'French',
},
{
language_id: 'ko',
name: 'Korean',
},
{
language_id: 'pt',
name: 'Portuguese',
},
{
language_id: 'it',
name: 'Italian',
},
{
language_id: 'es',
name: 'Spanish',
},
{
language_id: 'ru',
name: 'Russian',
},
{
language_id: 'id',
name: 'Indonesian',
},
{
language_id: 'nl',
name: 'Dutch',
},
{
language_id: 'tr',
name: 'Turkish',
},
{
language_id: 'fil',
name: 'Filipino',
},
{
language_id: 'pl',
name: 'Polish',
},
{
language_id: 'sv',
name: 'Swedish',
},
{
language_id: 'bg',
name: 'Bulgarian',
},
{
language_id: 'ro',
name: 'Romanian',
},
{
language_id: 'ar',
name: 'Arabic',
},
{
language_id: 'cs',
name: 'Czech',
},
{
language_id: 'el',
name: 'Greek',
},
{
language_id: 'fi',
name: 'Finnish',
},
{
language_id: 'hr',
name: 'Croatian',
},
{
language_id: 'ms',
name: 'Malay',
},
{
language_id: 'sk',
name: 'Slovak',
},
{
language_id: 'da',
name: 'Danish',
},
{
language_id: 'ta',
name: 'Tamil',
},
{
language_id: 'uk',
name: 'Ukrainian',
},
{
language_id: 'vi',
name: 'Vietnamese',
},
{
language_id: 'no',
name: 'Norwegian',
},
{
language_id: 'hu',
name: 'Hungarian',
},
],
model_rates: {
character_cost_multiplier: 0.5,
},
concurrency_group: 'turbo',
},
{
model_id: 'eleven_turbo_v2',
name: 'Eleven Turbo v2',
can_be_finetuned: true,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our English-only, low latency model. Best for developer use cases where speed matters and you only need English. Performance is on par with Turbo v2.5.',
requires_alpha_access: false,
max_characters_request_free_user: 30000,
max_characters_request_subscribed_user: 30000,
maximum_text_length_per_request: 30000,
languages: [
{
language_id: 'en',
name: 'English',
},
],
model_rates: {
character_cost_multiplier: 0.5,
},
concurrency_group: 'turbo',
},
{
model_id: 'eleven_flash_v2',
name: 'Eleven Flash v2',
can_be_finetuned: true,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our ultra low latency model in english. Ideal for conversational use cases.',
requires_alpha_access: false,
max_characters_request_free_user: 30000,
max_characters_request_subscribed_user: 30000,
maximum_text_length_per_request: 30000,
languages: [
{
language_id: 'en',
name: 'English',
},
],
model_rates: {
character_cost_multiplier: 0.5,
},
concurrency_group: 'turbo',
},
{
model_id: 'eleven_english_sts_v2',
name: 'Eleven English v2',
can_be_finetuned: false,
can_do_text_to_speech: false,
can_do_voice_conversion: true,
can_use_style: true,
can_use_speaker_boost: true,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our state-of-the-art speech to speech model suitable for scenarios where you need maximum control over the content and prosody of your generations.',
requires_alpha_access: false,
max_characters_request_free_user: 5000,
max_characters_request_subscribed_user: 5000,
maximum_text_length_per_request: 5000,
languages: [
{
language_id: 'en',
name: 'English',
},
],
model_rates: {
character_cost_multiplier: 1,
},
concurrency_group: 'standard',
},
{
model_id: 'eleven_multilingual_v1',
name: 'Eleven Multilingual v1',
can_be_finetuned: false,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our first Multilingual model, capability of generating speech in 10 languages. Now outclassed by Multilingual v2 (for content creation) and Turbo v2.5 (for low latency use cases).',
requires_alpha_access: false,
max_characters_request_free_user: 10000,
max_characters_request_subscribed_user: 10000,
maximum_text_length_per_request: 10000,
languages: [
{
language_id: 'en',
name: 'English',
},
{
language_id: 'de',
name: 'German',
},
{
language_id: 'pl',
name: 'Polish',
},
{
language_id: 'es',
name: 'Spanish',
},
{
language_id: 'it',
name: 'Italian',
},
{
language_id: 'fr',
name: 'French',
},
{
language_id: 'pt',
name: 'Portuguese',
},
{
language_id: 'hi',
name: 'Hindi',
},
{
language_id: 'ar',
name: 'Arabic',
},
],
model_rates: {
character_cost_multiplier: 1,
},
concurrency_group: 'standard',
},
{
model_id: 'eleven_multilingual_sts_v2',
name: 'Eleven Multilingual v2',
can_be_finetuned: true,
can_do_text_to_speech: false,
can_do_voice_conversion: true,
can_use_style: true,
can_use_speaker_boost: true,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our cutting-edge, multilingual speech-to-speech model is designed for situations that demand unparalleled control over both the content and the prosody of the generated speech across various languages.',
requires_alpha_access: false,
max_characters_request_free_user: 10000,
max_characters_request_subscribed_user: 10000,
maximum_text_length_per_request: 10000,
languages: [
{
language_id: 'en',
name: 'English',
},
{
language_id: 'ja',
name: 'Japanese',
},
{
language_id: 'zh',
name: 'Chinese',
},
{
language_id: 'de',
name: 'German',
},
{
language_id: 'hi',
name: 'Hindi',
},
{
language_id: 'fr',
name: 'French',
},
{
language_id: 'ko',
name: 'Korean',
},
{
language_id: 'pt',
name: 'Portuguese',
},
{
language_id: 'it',
name: 'Italian',
},
{
language_id: 'es',
name: 'Spanish',
},
{
language_id: 'ru',
name: 'Russian',
},
{
language_id: 'id',
name: 'Indonesian',
},
{
language_id: 'nl',
name: 'Dutch',
},
{
language_id: 'tr',
name: 'Turkish',
},
{
language_id: 'fil',
name: 'Filipino',
},
{
language_id: 'pl',
name: 'Polish',
},
{
language_id: 'sv',
name: 'Swedish',
},
{
language_id: 'bg',
name: 'Bulgarian',
},
{
language_id: 'ro',
name: 'Romanian',
},
{
language_id: 'ar',
name: 'Arabic',
},
{
language_id: 'cs',
name: 'Czech',
},
{
language_id: 'el',
name: 'Greek',
},
{
language_id: 'fi',
name: 'Finnish',
},
{
language_id: 'hr',
name: 'Croatian',
},
{
language_id: 'ms',
name: 'Malay',
},
{
language_id: 'sk',
name: 'Slovak',
},
{
language_id: 'da',
name: 'Danish',
},
{
language_id: 'ta',
name: 'Tamil',
},
{
language_id: 'uk',
name: 'Ukrainian',
},
],
model_rates: {
character_cost_multiplier: 1,
},
concurrency_group: 'standard',
},
{
model_id: 'eleven_monolingual_v1',
name: 'Eleven English v1',
can_be_finetuned: false,
can_do_text_to_speech: true,
can_do_voice_conversion: false,
can_use_style: false,
can_use_speaker_boost: false,
serves_pro_voices: false,
token_cost_factor: 1,
description: 'Our first ever text to speech model. Now outclassed by Multilingual v2 (for content creation) and Turbo v2.5 (for low latency use cases).',
requires_alpha_access: false,
max_characters_request_free_user: 10000,
max_characters_request_subscribed_user: 10000,
maximum_text_length_per_request: 10000,
languages: [
{
language_id: 'en',
name: 'English',
},
],
model_rates: {
character_cost_multiplier: 1,
},
concurrency_group: 'standard',
},
]
+55 -69
View File
@@ -122,7 +122,7 @@ importers:
version: 3.0.0-rc.11(typescript@5.8.2)(vue-tsc@2.2.8(typescript@5.8.2))
unocss:
specifier: ^66.1.0-beta.5
version: 66.1.0-beta.5(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
version: 66.1.0-beta.5(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
vite:
specifier: ^6.2.2
version: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -133,6 +133,55 @@ importers:
specifier: ^3.0.9
version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.10)(@vitest/browser@3.0.9)(jiti@2.4.2)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(less@4.2.2)(msw@2.7.3(@types/node@22.13.10)(typescript@5.8.2))(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
apps/realtime-audio:
dependencies:
'@vueuse/core':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
'@xsai/generate-text':
specifier: 'catalog:'
version: 0.1.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.1.3
'@xsai/stream-text':
specifier: 'catalog:'
version: 0.1.3
defu:
specifier: ^6.1.4
version: 6.1.4
es-toolkit:
specifier: ^1.33.0
version: 1.33.0
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.2)
devDependencies:
'@iconify-json/solar':
specifier: ^1.2.2
version: 1.2.2
'@unocss/reset':
specifier: ^66.1.0-beta.5
version: 66.1.0-beta.5
'@vitejs/plugin-vue':
specifier: ^5.2.3
version: 5.2.3(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
superjson:
specifier: ^2.2.2
version: 2.2.2
unplugin-vue-router:
specifier: ^0.12.0
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vite:
specifier: ^6.2.2
version: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
vue-router:
specifier: ^4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.8.2))
vue-tsc:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2(typescript@5.8.2)
apps/stage-tamagotchi:
dependencies:
'@11labs/client':
@@ -447,7 +496,7 @@ importers:
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-macros:
specifier: ^2.14.5
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -15704,16 +15753,6 @@ snapshots:
unhead: 2.0.0-rc.13
vue: 3.5.13(typescript@5.8.2)
'@unocss/astro@66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))':
dependencies:
'@unocss/core': 66.1.0-beta.5
'@unocss/reset': 66.1.0-beta.5
'@unocss/vite': 66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
optionalDependencies:
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
transitivePeerDependencies:
- vue
'@unocss/astro@66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@unocss/core': 66.1.0-beta.5
@@ -15788,17 +15827,6 @@ snapshots:
dependencies:
'@unocss/core': 66.1.0-beta.5
'@unocss/inspector@66.1.0-beta.5':
dependencies:
'@unocss/core': 66.1.0-beta.5
'@unocss/rule-utils': 66.1.0-beta.5
colorette: 2.0.20
gzip-size: 6.0.0
sirv: 3.0.1
vue-flow-layout: 0.1.1(vue@packages+stage-ui+@formkit+auto-animate+vue)
transitivePeerDependencies:
- vue
'@unocss/inspector@66.1.0-beta.5(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@unocss/core': 66.1.0-beta.5
@@ -15921,20 +15949,6 @@ snapshots:
dependencies:
'@unocss/core': 66.1.0-beta.5
'@unocss/vite@66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@unocss/config': 66.1.0-beta.5
'@unocss/core': 66.1.0-beta.5
'@unocss/inspector': 66.1.0-beta.5
chokidar: 3.6.0
magic-string: 0.30.17
tinyglobby: 0.2.12
unplugin-utils: 0.2.4
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
transitivePeerDependencies:
- vue
'@unocss/vite@66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -24007,34 +24021,6 @@ snapshots:
'@unocss/preset-mini': 65.5.0
unocss: 66.1.0-beta.5(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
unocss@66.1.0-beta.5(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
dependencies:
'@unocss/astro': 66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
'@unocss/cli': 66.1.0-beta.5
'@unocss/core': 66.1.0-beta.5
'@unocss/postcss': 66.1.0-beta.5(postcss@8.5.3)
'@unocss/preset-attributify': 66.1.0-beta.5
'@unocss/preset-icons': 66.1.0-beta.5
'@unocss/preset-mini': 66.1.0-beta.5
'@unocss/preset-tagify': 66.1.0-beta.5
'@unocss/preset-typography': 66.1.0-beta.5
'@unocss/preset-uno': 66.1.0-beta.5
'@unocss/preset-web-fonts': 66.1.0-beta.5
'@unocss/preset-wind': 66.1.0-beta.5
'@unocss/preset-wind3': 66.1.0-beta.5
'@unocss/preset-wind4': 66.1.0-beta.5
'@unocss/transformer-attributify-jsx': 66.1.0-beta.5
'@unocss/transformer-compile-class': 66.1.0-beta.5
'@unocss/transformer-directives': 66.1.0-beta.5
'@unocss/transformer-variant-group': 66.1.0-beta.5
'@unocss/vite': 66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
optionalDependencies:
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
transitivePeerDependencies:
- postcss
- supports-color
- vue
unocss@66.1.0-beta.5(postcss@8.5.3)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@unocss/astro': 66.1.0-beta.5(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
@@ -24117,9 +24103,9 @@ snapshots:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.36.0)
'@vueuse/core': 13.0.0(vue@3.5.13(typescript@5.8.2))
unplugin-combine@1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
unplugin-combine@1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)):
optionalDependencies:
esbuild: 0.19.12
esbuild: 0.25.0
rollup: 2.79.1
unplugin: 1.16.1
vite: 6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -24178,7 +24164,7 @@ snapshots:
transitivePeerDependencies:
- vue
unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2)):
unplugin-vue-macros@2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@vue-macros/better-define': 1.11.4(vue@3.5.13(typescript@5.8.2))
'@vue-macros/boolean-prop': 0.5.5(vue@3.5.13(typescript@5.8.2))
@@ -24210,7 +24196,7 @@ snapshots:
'@vue-macros/short-vmodel': 1.5.5(vue@3.5.13(typescript@5.8.2))
'@vue-macros/volar': 0.30.15(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin: 1.16.1
unplugin-combine: 1.2.1(esbuild@0.19.12)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-combine: 1.2.1(esbuild@0.25.0)(rollup@2.79.1)(unplugin@1.16.1)(vite@6.2.2(@types/node@22.13.10)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
unplugin-vue-define-options: 1.5.5(vue@3.5.13(typescript@5.8.2))
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies: