feat(stage-web|stage-tamagotchi): speech finished
This commit is contained in:
@@ -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>
|
||||
Executable
+13
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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>>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -0,0 +1,254 @@
|
||||
export interface ChunkLengthScheduleOptions {
|
||||
/**
|
||||
* This is an advanced setting that most users shouldn’t 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 shouldn’t 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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(' '),
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
Generated
+55
-69
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user