fix(stage-web|stage-tamagotchi|stage-ui): voice & models

This commit is contained in:
Neko Ayaka
2025-03-15 22:05:11 +08:00
parent e764d33404
commit b8d93b6d7d
5 changed files with 305 additions and 133 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RadioCardSimple, Range } from '@proj-airi/stage-ui/components'
import { RadioCardDetailManySelect, RadioCardSimple, Range } from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
@@ -7,7 +7,10 @@ import { RouterLink, useRouter } from 'vue-router'
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { availableProviders, availableProvidersMetadata } = storeToRefs(providersStore)
const {
availableProviders,
availableProvidersMetadata,
} = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
@@ -18,6 +21,7 @@ const {
speechProviderError,
supportsSSML,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const router = useRouter()
@@ -29,6 +33,8 @@ const ssmlExample = ref(`<speak>
</voice>
</speak>`)
const voiceSearchQuery = ref('')
onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
})
@@ -38,6 +44,11 @@ function updateVoiceName(value: string) {
updateSSMLExample()
}
function updateCustomVoiceName(value: string) {
voiceName.value = value
updateSSMLExample()
}
function updateSSMLExample() {
ssmlExample.value = `<speak>
Hello, my name is <voice name="${voiceName.value || 'Default'}">
@@ -136,7 +147,7 @@ function updateSSMLExample() {
<!-- Error state -->
<div
v-else-if="speechProviderError"
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"
class="flex items-center gap-3 border border-2 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">
@@ -145,9 +156,129 @@ function updateSSMLExample() {
</div>
</div>
<!-- Voice configuration form -->
<div v-else class="space-y-6">
<!-- Voice selection -->
<!-- Voice selection with RadioCardDetailManySelect -->
<div v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0" class="space-y-6">
<RadioCardDetailManySelect
v-model="voiceName"
v-model:search-query="voiceSearchQuery"
:items="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
name: voice.name,
description: voice.description || `${voice.labels?.gender || 'Neutral'} voice`,
customizable: true,
}))"
:searchable="true"
search-placeholder="Search voices..."
search-no-results-title="No voices found"
search-no-results-description="Try a different search term or enter a custom voice name"
search-results-text="{count} of {total} voices"
custom-input-placeholder="Enter custom voice name"
expand-button-text="Show more voices"
collapse-button-text="Show fewer voices"
@update:custom-value="updateCustomVoiceName"
/>
<!-- 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 class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">
Pitch Adjustment (%)
</label>
<div class="flex items-center gap-3">
<Range
v-model="pitch" :min="-50" :max="50" :step="5"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ pitch }}%</span>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium">
Speech Rate
</label>
<div class="flex items-center gap-3">
<Range
v-model="rate" :min="0.5" :max="2" :step="0.1"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ rate }}x</span>
</div>
</div>
</div>
<!-- SSML Support -->
<div v-if="supportsSSML" class="border border-2 border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div class="mb-3 flex items-center justify-between">
<label class="font-medium">SSML Support</label>
<div class="relative mr-2 inline-block w-10 select-none align-middle">
<input
id="ssml-toggle"
v-model="ssmlEnabled"
type="checkbox"
class="sr-only"
>
<label
for="ssml-toggle"
class="block h-6 cursor-pointer overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-700"
>
<span
:class="{ 'translate-x-4': ssmlEnabled, 'translate-x-0': !ssmlEnabled }"
class="block h-6 w-6 transform rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
/>
</label>
</div>
</div>
<p class="mb-3 text-sm text-neutral-500">
Enable Speech Synthesis Markup Language for more control over speech output
</p>
<div v-if="ssmlEnabled" class="mt-3">
<label class="mb-1 block text-sm font-medium">
SSML Example
</label>
<pre class="overflow-auto rounded bg-neutral-50 p-3 text-xs dark:bg-neutral-800">{{ ssmlExample }}</pre>
</div>
</div>
</div>
<!-- No voices available -->
<div
v-else-if="!isLoadingSpeechProviderVoices && (!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0)"
class="flex items-center gap-3 border border-2 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">No voices available</span>
<span class="text-sm text-amber-600 dark:text-amber-400">
No voices were found for this provider. You can enter a custom voice name below.
</span>
</div>
</div>
<!-- Manual voice input when no voices are available -->
<div v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0" class="mt-2 space-y-6">
<div>
<label class="mb-1 block text-sm font-medium">
Voice Name
@@ -194,6 +325,7 @@ function updateSSMLExample() {
<Range
v-model="pitch" :min="-50" :max="50" :step="5"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ pitch }}%</span>
</div>
@@ -206,6 +338,7 @@ function updateSSMLExample() {
<Range
v-model="rate" :min="0.5" :max="2" :step="0.1"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ rate }}x</span>
</div>
@@ -213,7 +346,7 @@ function updateSSMLExample() {
</div>
<!-- SSML Support -->
<div v-if="supportsSSML" class="border border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div v-if="supportsSSML" class="border border-2 border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div class="mb-3 flex items-center justify-between">
<label class="font-medium">SSML Support</label>
<div class="relative mr-2 inline-block w-10 select-none align-middle">
@@ -253,3 +386,9 @@ function updateSSMLExample() {
<div text="40" i-lucide:volume-2 />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RadioCardSimple, Range } from '@proj-airi/stage-ui/components'
import { RadioCardDetailManySelect, RadioCardSimple, Range } from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
@@ -7,7 +7,10 @@ import { RouterLink, useRouter } from 'vue-router'
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { availableProviders, availableProvidersMetadata } = storeToRefs(providersStore)
const {
availableProviders,
availableProvidersMetadata,
} = storeToRefs(providersStore)
const {
activeSpeechProvider,
activeSpeechModel,
@@ -18,6 +21,7 @@ const {
speechProviderError,
supportsSSML,
ssmlEnabled,
availableVoices,
} = storeToRefs(speechStore)
const router = useRouter()
@@ -29,6 +33,8 @@ const ssmlExample = ref(`<speak>
</voice>
</speak>`)
const voiceSearchQuery = ref('')
onMounted(async () => {
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
})
@@ -38,6 +44,11 @@ function updateVoiceName(value: string) {
updateSSMLExample()
}
function updateCustomVoiceName(value: string) {
voiceName.value = value
updateSSMLExample()
}
function updateSSMLExample() {
ssmlExample.value = `<speak>
Hello, my name is <voice name="${voiceName.value || 'Default'}">
@@ -136,7 +147,7 @@ function updateSSMLExample() {
<!-- Error state -->
<div
v-else-if="speechProviderError"
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"
class="flex items-center gap-3 border border-2 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">
@@ -145,9 +156,129 @@ function updateSSMLExample() {
</div>
</div>
<!-- Voice configuration form -->
<div v-else class="space-y-6">
<!-- Voice selection -->
<!-- Voice selection with RadioCardDetailManySelect -->
<div v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0" class="space-y-6">
<RadioCardDetailManySelect
v-model="voiceName"
v-model:search-query="voiceSearchQuery"
:items="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
name: voice.name,
description: voice.description || `${voice.labels?.gender || 'Neutral'} voice`,
customizable: true,
}))"
:searchable="true"
search-placeholder="Search voices..."
search-no-results-title="No voices found"
search-no-results-description="Try a different search term or enter a custom voice name"
search-results-text="{count} of {total} voices"
custom-input-placeholder="Enter custom voice name"
expand-button-text="Show more voices"
collapse-button-text="Show fewer voices"
@update:custom-value="updateCustomVoiceName"
/>
<!-- 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 class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">
Pitch Adjustment (%)
</label>
<div class="flex items-center gap-3">
<Range
v-model="pitch" :min="-50" :max="50" :step="5"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ pitch }}%</span>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium">
Speech Rate
</label>
<div class="flex items-center gap-3">
<Range
v-model="rate" :min="0.5" :max="2" :step="0.1"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ rate }}x</span>
</div>
</div>
</div>
<!-- SSML Support -->
<div v-if="supportsSSML" class="border border-2 border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div class="mb-3 flex items-center justify-between">
<label class="font-medium">SSML Support</label>
<div class="relative mr-2 inline-block w-10 select-none align-middle">
<input
id="ssml-toggle"
v-model="ssmlEnabled"
type="checkbox"
class="sr-only"
>
<label
for="ssml-toggle"
class="block h-6 cursor-pointer overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-700"
>
<span
:class="{ 'translate-x-4': ssmlEnabled, 'translate-x-0': !ssmlEnabled }"
class="block h-6 w-6 transform rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
/>
</label>
</div>
</div>
<p class="mb-3 text-sm text-neutral-500">
Enable Speech Synthesis Markup Language for more control over speech output
</p>
<div v-if="ssmlEnabled" class="mt-3">
<label class="mb-1 block text-sm font-medium">
SSML Example
</label>
<pre class="overflow-auto rounded bg-neutral-50 p-3 text-xs dark:bg-neutral-800">{{ ssmlExample }}</pre>
</div>
</div>
</div>
<!-- No voices available -->
<div
v-else-if="!isLoadingSpeechProviderVoices && (!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0)"
class="flex items-center gap-3 border border-2 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">No voices available</span>
<span class="text-sm text-amber-600 dark:text-amber-400">
No voices were found for this provider. You can enter a custom voice name below.
</span>
</div>
</div>
<!-- Manual voice input when no voices are available -->
<div v-if="!availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0" class="mt-2 space-y-6">
<div>
<label class="mb-1 block text-sm font-medium">
Voice Name
@@ -194,6 +325,7 @@ function updateSSMLExample() {
<Range
v-model="pitch" :min="-50" :max="50" :step="5"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ pitch }}%</span>
</div>
@@ -206,6 +338,7 @@ function updateSSMLExample() {
<Range
v-model="rate" :min="0.5" :max="2" :step="0.1"
class="w-full"
@change="updateSSMLExample"
/>
<span class="w-12 text-center">{{ rate }}x</span>
</div>
@@ -213,7 +346,7 @@ function updateSSMLExample() {
</div>
<!-- SSML Support -->
<div v-if="supportsSSML" class="border border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div v-if="supportsSSML" class="border border-2 border-neutral-200 rounded-lg p-4 dark:border-neutral-700">
<div class="mb-3 flex items-center justify-between">
<label class="font-medium">SSML Support</label>
<div class="relative mr-2 inline-block w-10 select-none align-middle">
@@ -253,3 +386,9 @@ function updateSSMLExample() {
<div text="40" i-lucide:volume-2 />
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -105,7 +105,7 @@ function updateCustomValue(value: string) {
<!-- No search results -->
<div
v-if="searchQuery && filteredItems.length === 0"
class="flex items-center gap-3 border border-amber-200 rounded-xl bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
class="flex items-center gap-3 border border-2 border-amber-200 rounded-xl 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">
@@ -122,7 +122,7 @@ function updateCustomValue(value: string) {
<div
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col gap-4 overflow-x-auto pb-4"
:class="[
isListExpanded ? 'md:grid-cols-2 md:grid-flow-row md:auto-cols-auto' : '',
isListExpanded ? 'grid-cols-1 md:grid-cols-2 grid-flow-row auto-cols-auto' : '',
]"
transition="all duration-200 ease-in-out"
style="scroll-snap-type: x mandatory;"
@@ -153,7 +153,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-[calc(100%-16px-40px-16px)]' : 'mt-0 w-full rounded-lg',
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',
]"
>
<button
+5 -116
View File
@@ -1,8 +1,10 @@
import type { Voice } from '../fix/voice'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { Voice, voiceList, voiceMap } from '../../constants/elevenlabs'
import { voiceList, voiceMap } from '../../constants/elevenlabs'
import { useProvidersStore } from '../providers'
export const useSpeechStore = defineStore('speech', () => {
@@ -17,20 +19,9 @@ export const useSpeechStore = defineStore('speech', () => {
const ssmlEnabled = useLocalStorage('settings/speech/ssml-enabled', false)
const isLoadingSpeechProviderVoices = ref(false)
const speechProviderError = ref<string | null>(null)
const availableVoices = ref<Record<string, VoiceInfo[]>>({})
const availableVoices = ref<Record<string, Voice[]>>({})
const selectedLanguage = useLocalStorage('settings/speech/language', 'en-US')
// Voice info interface
interface VoiceInfo {
id: string
name: string
provider: string
description?: string
gender?: string
previewUrl?: string
language?: string
}
// Computed properties
const availableSpeechProvidersMetadata = computed(() => {
// Filter only speech providers from all available providers
@@ -69,31 +60,6 @@ export const useSpeechStore = defineStore('speech', () => {
return ['elevenlabs', 'microsoft', 'google', 'amazon'].includes(providerId)
}
// Actions
function setActiveSpeechProvider(provider: string) {
activeSpeechProvider.value = provider
}
function setActiveSpeechModel(model: string) {
activeSpeechModel.value = model
}
function setVoiceName(name: string) {
voiceName.value = name
}
const voiceId = computed(() => {
return voiceMap[voiceName.value as Voice]
})
function setSSMLEnabled(enabled: boolean) {
ssmlEnabled.value = enabled
}
function setLanguage(language: string) {
selectedLanguage.value = language
}
function resetVoiceSettings() {
voiceName.value = ''
pitch.value = 0
@@ -110,56 +76,7 @@ export const useSpeechStore = defineStore('speech', () => {
speechProviderError.value = null
try {
// In a real implementation, you would fetch voices from the provider
// For now, we'll use our predefined voices for ElevenLabs
await new Promise(resolve => setTimeout(resolve, 500))
if (provider === 'elevenlabs') {
// Use the predefined voices from elevenlabs.ts
const allVoices: VoiceInfo[] = []
for (const language in voiceList) {
const voices = voiceList[language]
for (const voice of voices) {
allVoices.push({
id: voiceMap[voice],
name: voice,
provider: 'elevenlabs',
language,
description: getVoiceDescription(voice),
gender: getVoiceGender(voice),
})
}
}
availableVoices.value[provider] = allVoices
}
else if (provider === 'microsoft') {
availableVoices.value[provider] = [
{
id: 'en-US-AriaNeural',
name: 'Aria',
provider: 'microsoft',
description: 'Microsoft neural voice (female)',
gender: 'female',
language: 'en-US',
},
{
id: 'en-US-GuyNeural',
name: 'Guy',
provider: 'microsoft',
description: 'Microsoft neural voice (male)',
gender: 'male',
language: 'en-US',
},
]
}
else {
// For other providers, return an empty array for now
availableVoices.value[provider] = []
}
return availableVoices.value[provider]
return await providersStore.getProviderMetadata(provider).capabilities.listVoices?.(providersStore.getProviderConfig(provider)) || []
}
catch (error) {
console.error(`Error fetching voices for ${provider}:`, error)
@@ -171,27 +88,6 @@ export const useSpeechStore = defineStore('speech', () => {
}
}
// Helper function to get voice descriptions
function getVoiceDescription(voice: string): string {
const descriptions: Record<string, string> = {
[Voice.Myriam]: 'Professional female voice with clear articulation',
[Voice.Beatrice]: 'Mature and sophisticated female voice',
[Voice.Camilla_KM]: 'Friendly and approachable female voice',
[Voice.SallySunshine]: 'Cheerful and upbeat female voice',
[Voice.Annie]: 'Young and energetic female voice',
[Voice.KawaiiAerisita]: 'Cute and playful female voice',
[Voice.Morioki]: 'Deep and authoritative Japanese male voice',
}
return descriptions[voice as Voice] || 'ElevenLabs voice'
}
// Helper function to get voice gender
function getVoiceGender(voice: string): string {
const maleVoices = [Voice.Morioki]
return maleVoices.includes(voice as Voice) ? 'male' : 'female'
}
// Get voices for a specific provider
function getVoicesForProvider(provider: string) {
return availableVoices.value[provider] || []
@@ -241,7 +137,6 @@ export const useSpeechStore = defineStore('speech', () => {
activeSpeechProvider,
activeSpeechModel,
voiceName,
voiceId,
pitch,
rate,
ssmlEnabled,
@@ -256,12 +151,6 @@ export const useSpeechStore = defineStore('speech', () => {
availableLanguages,
availableVoicesForLanguage,
// Actions
setActiveSpeechProvider,
setActiveSpeechModel,
setVoiceName,
setSSMLEnabled,
setLanguage,
resetVoiceSettings,
loadVoicesForProvider,
getVoicesForProvider,
@@ -709,8 +709,13 @@ export const useProvidersStore = defineStore('providers', () => {
return availableProviders.value.map(id => getProviderMetadata(id))
})
function getProviderConfig(providerId: string) {
return providerCredentials.value[providerId]
}
return {
providers: providerCredentials,
getProviderConfig,
availableProviders,
configuredProviders,
providerMetadata,