refactor(stage-web|stage-tamagotchi|stage-ui): components
This commit is contained in:
@@ -3,419 +3,98 @@ import type { UnElevenLabsOptions } from '@xsai-ext/providers-local'
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
|
||||
import {
|
||||
FieldCheckbox,
|
||||
FieldRange,
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
ProviderSettingsLayout,
|
||||
TestDummyMarker,
|
||||
SpeechPlayground,
|
||||
SpeechProviderSettings,
|
||||
SpeechVoiceSettings,
|
||||
} 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'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const providerId = 'elevenlabs'
|
||||
const defaultModel = 'eleven_multilingual_v2'
|
||||
|
||||
// Default voice settings specific to ElevenLabs
|
||||
const defaultVoiceSettings = {
|
||||
similarityBoost: 0.75,
|
||||
stability: 0.5,
|
||||
speed: 1.0,
|
||||
style: 0,
|
||||
useSpeakerBoost: true,
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
const selectedLanguage = ref('en-US')
|
||||
const activeSpeechVoice = ref('')
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
// For playground
|
||||
const testText = ref('Hello! This is a test of the ElevenLabs voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'elevenlabs'
|
||||
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
|
||||
},
|
||||
// Get available voices for ElevenLabs
|
||||
const availableVoices = computed(() => {
|
||||
return speechStore.availableVoices[providerId] || []
|
||||
})
|
||||
|
||||
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
|
||||
},
|
||||
// Get available languages
|
||||
const availableLanguages = computed(() => {
|
||||
return speechStore.availableLanguages
|
||||
})
|
||||
|
||||
// Voice settings as individual computed properties
|
||||
const similarityBoost = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.similarityBoost ?? 0.75,
|
||||
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.similarityBoost = value
|
||||
},
|
||||
})
|
||||
|
||||
const stability = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.stability ?? 0.5,
|
||||
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.stability = 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 style = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.style ?? 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.style = value
|
||||
},
|
||||
})
|
||||
|
||||
const useSpeakerBoost = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.useSpeakerBoost !== false,
|
||||
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.useSpeakerBoost = value
|
||||
},
|
||||
})
|
||||
|
||||
// Speech settings
|
||||
const availableVoices = computed(() => speechStore.availableVoicesForLanguage)
|
||||
|
||||
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) {
|
||||
similarityBoost.value = (providers.value[providerId].voiceSettings as any)?.similarityBoost ?? 0.75
|
||||
stability.value = (providers.value[providerId].voiceSettings as any)?.stability ?? 0.5
|
||||
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
|
||||
style.value = (providers.value[providerId].voiceSettings as any)?.style ?? 0
|
||||
useSpeakerBoost.value = (providers.value[providerId].voiceSettings as any)?.useSpeakerBoost !== false
|
||||
}
|
||||
|
||||
// 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 || '',
|
||||
voiceSettings: {
|
||||
similarityBoost: similarityBoost.value,
|
||||
stability: stability.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Watch all settings and update the provider configuration
|
||||
watch([apiKey, baseUrl, similarityBoost, stability, speed, style, useSpeakerBoost], debouncedUpdate)
|
||||
|
||||
// Function to generate speech
|
||||
async function generateTestSpeech() {
|
||||
if (!testText.value.trim())
|
||||
return
|
||||
|
||||
// Generate speech with ElevenLabs-specific parameters
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
if (!activeSpeechVoice.value) {
|
||||
console.error('No active speech voice selected')
|
||||
return
|
||||
}
|
||||
// Get provider configuration
|
||||
const providerConfig = providersStore.getProviderConfig(providerId)
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
// Get model from configuration or use default
|
||||
const model = providerConfig.model as string | undefined || defaultModel
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const response = await generateSpeech({
|
||||
...provider.speech('eleven_multilingual_v2', {
|
||||
voiceSettings: {
|
||||
stability: stability.value,
|
||||
similarityBoost: similarityBoost.value,
|
||||
// @ts-expect-error -- missing type
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}),
|
||||
input: testText.value,
|
||||
voice: activeSpeechVoice.value,
|
||||
})
|
||||
|
||||
// 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),
|
||||
}
|
||||
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
input,
|
||||
voiceId,
|
||||
{
|
||||
...providerConfig,
|
||||
...defaultVoiceSettings,
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName" :provider-icon="providerMetadata?.icon"
|
||||
:on-back="() => router.back()"
|
||||
<SpeechProviderSettings
|
||||
:provider-id="providerId"
|
||||
:default-model="defaultModel"
|
||||
:additional-settings="defaultVoiceSettings"
|
||||
>
|
||||
<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-" />
|
||||
</ProviderBasicSettings>
|
||||
<!-- Voice settings specific to ElevenLabs -->
|
||||
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
|
||||
<SpeechVoiceSettings
|
||||
:settings="voiceSettings"
|
||||
:show-similarity-boost="true"
|
||||
:show-stability="true"
|
||||
:show-speed="true"
|
||||
:show-style="true"
|
||||
:show-speaker-boost="true"
|
||||
@update="updateVoiceSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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="similarityBoost"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
|
||||
:min="0" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="stability"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
|
||||
:min="0" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="speed"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speed.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speed.description')" :min="0.7"
|
||||
:max="1.2" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="style"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')" :min="0"
|
||||
:max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldCheckbox
|
||||
v-model="useSpeakerBoost"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
/>
|
||||
<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="activeSpeechVoice"
|
||||
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="voice in availableVoices" :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() || !apiKey"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || !testText.trim() || !apiKey }"
|
||||
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="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>
|
||||
<!-- Replace the default playground with our standalone component -->
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:available-languages="availableLanguages"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
|
||||
+98
-376
@@ -3,57 +3,32 @@ 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,
|
||||
SpeechPlayground,
|
||||
SpeechProviderSettings,
|
||||
SpeechVoiceSettings,
|
||||
} 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'
|
||||
import { computed } from 'vue'
|
||||
|
||||
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 defaultModel = 'v1'
|
||||
|
||||
const apiKey = computed({
|
||||
get: () => providers.value[providerId]?.apiKey as string | undefined || '',
|
||||
set: (value) => {
|
||||
if (!providers.value[providerId])
|
||||
providers.value[providerId] = {}
|
||||
// Default voice settings specific to Microsoft Speech
|
||||
const defaultVoiceSettings = {
|
||||
pitch: 0,
|
||||
speed: 1.0,
|
||||
volume: 0,
|
||||
}
|
||||
|
||||
providers.value[providerId].apiKey = value
|
||||
},
|
||||
})
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// Additional settings specific to Microsoft Speech (region)
|
||||
const region = computed({
|
||||
get: () => providers.value[providerId]?.region as string | undefined || providerMetadata.value?.defaultOptions?.region as string | undefined || 'eastasia',
|
||||
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
|
||||
set: (value) => {
|
||||
if (!providers.value[providerId])
|
||||
providers.value[providerId] = {}
|
||||
@@ -62,359 +37,106 @@ const region = computed({
|
||||
},
|
||||
})
|
||||
|
||||
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] = {}
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
providers.value[providerId].baseUrl = value
|
||||
},
|
||||
// Get available voices for Microsoft Speech
|
||||
const availableVoices = computed(() => {
|
||||
return speechStore.availableVoices[providerId] || []
|
||||
})
|
||||
|
||||
// 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
|
||||
},
|
||||
// Get available languages
|
||||
const availableLanguages = computed(() => {
|
||||
return speechStore.availableLanguages
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Generate speech with Microsoft-specific parameters
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boolean) {
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
// Get provider configuration
|
||||
const providerConfig = providersStore.getProviderConfig(providerId)
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
// Get model from configuration or use default
|
||||
const model = providerConfig.model as string | undefined || defaultModel
|
||||
|
||||
// For Microsoft Speech, we need to ensure we're using the right region
|
||||
const options = {
|
||||
...providerConfig,
|
||||
region: region.value,
|
||||
disableSsml: !useSSML, // If useSSML is true, we don't disable SSML
|
||||
}
|
||||
|
||||
// If not using SSML and we have a voice, generate SSML
|
||||
if (!useSSML && voiceId) {
|
||||
const voice = availableVoices.value.find(v => v.id === voiceId)
|
||||
if (voice) {
|
||||
const ssml = speechStore.generateSSML(
|
||||
input,
|
||||
voice,
|
||||
)
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
ssml,
|
||||
voiceId,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
// Either using direct SSML or no voice found
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
input,
|
||||
voiceId,
|
||||
options,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName" :provider-icon="providerMetadata?.icon"
|
||||
:on-back="() => router.back()"
|
||||
<SpeechProviderSettings
|
||||
:provider-id="providerId"
|
||||
:default-model="defaultModel"
|
||||
:additional-settings="defaultVoiceSettings"
|
||||
>
|
||||
<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>
|
||||
<!-- Basic settings specific to Microsoft Speech -->
|
||||
<template #basic-settings>
|
||||
<FieldInput
|
||||
v-model="region"
|
||||
label="Region"
|
||||
description="Speech Service region"
|
||||
placeholder="eastasia"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- Voice settings specific to Microsoft Speech -->
|
||||
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
|
||||
<SpeechVoiceSettings
|
||||
:settings="voiceSettings"
|
||||
:show-pitch="true"
|
||||
:show-speed="true"
|
||||
:show-volume="true"
|
||||
@update="updateVoiceSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- Replace the default playground with our standalone component -->
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:available-languages="availableLanguages"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
default-text="Hello! This is a test of the Microsoft Speech synthesis."
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
|
||||
@@ -3,419 +3,98 @@ import type { UnElevenLabsOptions } from '@xsai-ext/providers-local'
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
|
||||
import {
|
||||
FieldCheckbox,
|
||||
FieldRange,
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
ProviderSettingsLayout,
|
||||
TestDummyMarker,
|
||||
SpeechPlayground,
|
||||
SpeechProviderSettings,
|
||||
SpeechVoiceSettings,
|
||||
} 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'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const providerId = 'elevenlabs'
|
||||
const defaultModel = 'eleven_multilingual_v2'
|
||||
|
||||
// Default voice settings specific to ElevenLabs
|
||||
const defaultVoiceSettings = {
|
||||
similarityBoost: 0.75,
|
||||
stability: 0.5,
|
||||
speed: 1.0,
|
||||
style: 0,
|
||||
useSpeakerBoost: true,
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
const selectedLanguage = ref('en-US')
|
||||
const activeSpeechVoice = ref('')
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
// For playground
|
||||
const testText = ref('Hello! This is a test of the ElevenLabs voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// Get provider metadata
|
||||
const providerId = 'elevenlabs'
|
||||
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
|
||||
},
|
||||
// Get available voices for ElevenLabs
|
||||
const availableVoices = computed(() => {
|
||||
return speechStore.availableVoices[providerId] || []
|
||||
})
|
||||
|
||||
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
|
||||
},
|
||||
// Get available languages
|
||||
const availableLanguages = computed(() => {
|
||||
return speechStore.availableLanguages
|
||||
})
|
||||
|
||||
// Voice settings as individual computed properties
|
||||
const similarityBoost = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.similarityBoost ?? 0.75,
|
||||
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.similarityBoost = value
|
||||
},
|
||||
})
|
||||
|
||||
const stability = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.stability ?? 0.5,
|
||||
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.stability = 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 style = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.style ?? 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.style = value
|
||||
},
|
||||
})
|
||||
|
||||
const useSpeakerBoost = computed({
|
||||
get: () => (providers.value[providerId]?.voiceSettings as any)?.useSpeakerBoost !== false,
|
||||
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.useSpeakerBoost = value
|
||||
},
|
||||
})
|
||||
|
||||
// Speech settings
|
||||
const availableVoices = computed(() => speechStore.availableVoicesForLanguage)
|
||||
|
||||
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) {
|
||||
similarityBoost.value = (providers.value[providerId].voiceSettings as any)?.similarityBoost ?? 0.75
|
||||
stability.value = (providers.value[providerId].voiceSettings as any)?.stability ?? 0.5
|
||||
speed.value = (providers.value[providerId].voiceSettings as any)?.speed ?? 1.0
|
||||
style.value = (providers.value[providerId].voiceSettings as any)?.style ?? 0
|
||||
useSpeakerBoost.value = (providers.value[providerId].voiceSettings as any)?.useSpeakerBoost !== false
|
||||
}
|
||||
|
||||
// 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 || '',
|
||||
voiceSettings: {
|
||||
similarityBoost: similarityBoost.value,
|
||||
stability: stability.value,
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Watch all settings and update the provider configuration
|
||||
watch([apiKey, baseUrl, similarityBoost, stability, speed, style, useSpeakerBoost], debouncedUpdate)
|
||||
|
||||
// Function to generate speech
|
||||
async function generateTestSpeech() {
|
||||
if (!testText.value.trim())
|
||||
return
|
||||
|
||||
// Generate speech with ElevenLabs-specific parameters
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
if (!activeSpeechVoice.value) {
|
||||
console.error('No active speech voice selected')
|
||||
return
|
||||
}
|
||||
// Get provider configuration
|
||||
const providerConfig = providersStore.getProviderConfig(providerId)
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
// Get model from configuration or use default
|
||||
const model = providerConfig.model as string | undefined || defaultModel
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const response = await generateSpeech({
|
||||
...provider.speech('eleven_multilingual_v2', {
|
||||
voiceSettings: {
|
||||
stability: stability.value,
|
||||
similarityBoost: similarityBoost.value,
|
||||
// @ts-expect-error -- missing type
|
||||
speed: speed.value,
|
||||
style: style.value,
|
||||
useSpeakerBoost: useSpeakerBoost.value,
|
||||
},
|
||||
}),
|
||||
input: testText.value,
|
||||
voice: activeSpeechVoice.value,
|
||||
})
|
||||
|
||||
// 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),
|
||||
}
|
||||
// ElevenLabs doesn't need SSML conversion, but if SSML is provided, use it directly
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
input,
|
||||
voiceId,
|
||||
{
|
||||
...providerConfig,
|
||||
...defaultVoiceSettings,
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName" :provider-icon="providerMetadata?.icon"
|
||||
:on-back="() => router.back()"
|
||||
<SpeechProviderSettings
|
||||
:provider-id="providerId"
|
||||
:default-model="defaultModel"
|
||||
:additional-settings="defaultVoiceSettings"
|
||||
>
|
||||
<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-" />
|
||||
</ProviderBasicSettings>
|
||||
<!-- Voice settings specific to ElevenLabs -->
|
||||
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
|
||||
<SpeechVoiceSettings
|
||||
:settings="voiceSettings"
|
||||
:show-similarity-boost="true"
|
||||
:show-stability="true"
|
||||
:show-speed="true"
|
||||
:show-style="true"
|
||||
:show-speaker-boost="true"
|
||||
@update="updateVoiceSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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="similarityBoost"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
|
||||
:min="0" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="stability"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
|
||||
:min="0" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="speed"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speed.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speed.description')" :min="0.7"
|
||||
:max="1.2" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="style"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')" :min="0"
|
||||
:max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldCheckbox
|
||||
v-model="useSpeakerBoost"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
/>
|
||||
<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="activeSpeechVoice"
|
||||
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="voice in availableVoices" :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() || !apiKey"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || !testText.trim() || !apiKey }"
|
||||
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="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>
|
||||
<!-- Replace the default playground with our standalone component -->
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:available-languages="availableLanguages"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
default-text="Hello! This is a test of the ElevenLabs voice synthesis."
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
|
||||
@@ -3,57 +3,32 @@ 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,
|
||||
SpeechPlayground,
|
||||
SpeechProviderSettings,
|
||||
SpeechVoiceSettings,
|
||||
} 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'
|
||||
import { computed } from 'vue'
|
||||
|
||||
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 defaultModel = 'v1'
|
||||
|
||||
const apiKey = computed({
|
||||
get: () => providers.value[providerId]?.apiKey as string | undefined || '',
|
||||
set: (value) => {
|
||||
if (!providers.value[providerId])
|
||||
providers.value[providerId] = {}
|
||||
// Default voice settings specific to Microsoft Speech
|
||||
const defaultVoiceSettings = {
|
||||
pitch: 0,
|
||||
speed: 1.0,
|
||||
volume: 0,
|
||||
}
|
||||
|
||||
providers.value[providerId].apiKey = value
|
||||
},
|
||||
})
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// Additional settings specific to Microsoft Speech (region)
|
||||
const region = computed({
|
||||
get: () => providers.value[providerId]?.region as string | undefined || providerMetadata.value?.defaultOptions?.region as string | undefined || 'eastasia',
|
||||
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
|
||||
set: (value) => {
|
||||
if (!providers.value[providerId])
|
||||
providers.value[providerId] = {}
|
||||
@@ -62,359 +37,106 @@ const region = computed({
|
||||
},
|
||||
})
|
||||
|
||||
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] = {}
|
||||
// Check if API key is configured
|
||||
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
|
||||
|
||||
providers.value[providerId].baseUrl = value
|
||||
},
|
||||
// Get available voices for Microsoft Speech
|
||||
const availableVoices = computed(() => {
|
||||
return speechStore.availableVoices[providerId] || []
|
||||
})
|
||||
|
||||
// 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
|
||||
},
|
||||
// Get available languages
|
||||
const availableLanguages = computed(() => {
|
||||
return speechStore.availableLanguages
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Generate speech with Microsoft-specific parameters
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boolean) {
|
||||
const provider = providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string, UnMicrosoftOptions>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
// Get provider configuration
|
||||
const providerConfig = providersStore.getProviderConfig(providerId)
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
// Get model from configuration or use default
|
||||
const model = providerConfig.model as string | undefined || defaultModel
|
||||
|
||||
// For Microsoft Speech, we need to ensure we're using the right region
|
||||
const options = {
|
||||
...providerConfig,
|
||||
region: region.value,
|
||||
disableSsml: !useSSML, // If useSSML is true, we don't disable SSML
|
||||
}
|
||||
|
||||
// If not using SSML and we have a voice, generate SSML
|
||||
if (!useSSML && voiceId) {
|
||||
const voice = availableVoices.value.find(v => v.id === voiceId)
|
||||
if (voice) {
|
||||
const ssml = speechStore.generateSSML(
|
||||
input,
|
||||
voice,
|
||||
)
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
ssml,
|
||||
voiceId,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
// Either using direct SSML or no voice found
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
model,
|
||||
input,
|
||||
voiceId,
|
||||
options,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout
|
||||
:provider-name="providerMetadata?.localizedName" :provider-icon="providerMetadata?.icon"
|
||||
:on-back="() => router.back()"
|
||||
<SpeechProviderSettings
|
||||
:provider-id="providerId"
|
||||
:default-model="defaultModel"
|
||||
:additional-settings="defaultVoiceSettings"
|
||||
>
|
||||
<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>
|
||||
<!-- Basic settings specific to Microsoft Speech -->
|
||||
<template #basic-settings>
|
||||
<FieldInput
|
||||
v-model="region"
|
||||
label="Region"
|
||||
description="Speech Service region"
|
||||
placeholder="eastasia"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- Voice settings specific to Microsoft Speech -->
|
||||
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
|
||||
<SpeechVoiceSettings
|
||||
:settings="voiceSettings"
|
||||
:show-pitch="true"
|
||||
:show-speed="true"
|
||||
:show-volume="true"
|
||||
@update="updateVoiceSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- Replace the default playground with our standalone component -->
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:available-languages="availableLanguages"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
default-text="Hello! This is a test of the Microsoft Speech synthesis."
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
|
||||
@@ -159,6 +159,8 @@ words:
|
||||
- wavefile
|
||||
- webgpu
|
||||
- worklet
|
||||
- xast
|
||||
- xastscript
|
||||
- Xenova
|
||||
- xsai
|
||||
- xsschema
|
||||
|
||||
@@ -58,7 +58,10 @@
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"reka-ui": "^2.1.0"
|
||||
"reka-ui": "^2.1.0",
|
||||
"unist-builder": "^4.0.0",
|
||||
"xast-util-to-xml": "^4.0.0",
|
||||
"xastscript": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* Provider name to display in the header
|
||||
*/
|
||||
providerName?: string
|
||||
|
||||
/**
|
||||
* Provider icon CSS class
|
||||
*/
|
||||
providerIcon?: string
|
||||
|
||||
/**
|
||||
* Provider icon color CSS class
|
||||
*/
|
||||
providerIconColor?: string
|
||||
|
||||
/**
|
||||
* Optional handler for back button
|
||||
*/
|
||||
onBack?: () => void
|
||||
|
||||
/**
|
||||
* Optional title to display (overrides providerName)
|
||||
*/
|
||||
title?: string
|
||||
|
||||
/**
|
||||
* Optional subtitle to display
|
||||
*/
|
||||
subtitle?: string
|
||||
|
||||
/**
|
||||
* Optional category label to display above the title
|
||||
*/
|
||||
categoryLabel?: string
|
||||
}>()
|
||||
|
||||
// Expose event handlers
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
// Define slots
|
||||
defineSlots<{
|
||||
default: () => any
|
||||
title: () => any
|
||||
subtitle: () => any
|
||||
backButton: () => any
|
||||
headerExtra: () => any
|
||||
header: () => any
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Compute effective title
|
||||
const effectiveTitle = computed(() => props.title || props.providerName || '')
|
||||
|
||||
// Compute effective category label
|
||||
const effectiveCategoryLabel = computed(() => props.categoryLabel || t('common.provider'))
|
||||
|
||||
// Handle back button click
|
||||
function handleBackClick() {
|
||||
if (props.onBack) {
|
||||
props.onBack()
|
||||
}
|
||||
emit('back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header section -->
|
||||
<slot name="header">
|
||||
<div
|
||||
v-motion
|
||||
flex="~ row"
|
||||
:initial="{ opacity: 0, x: 10 }"
|
||||
:enter="{ opacity: 1, x: 0 }"
|
||||
:leave="{ opacity: 0, x: -10 }"
|
||||
:duration="250"
|
||||
mb-6 items-center gap-3
|
||||
>
|
||||
<!-- Back button -->
|
||||
<slot name="backButton">
|
||||
<button @click="handleBackClick">
|
||||
<div i-solar:alt-arrow-left-line-duotone text-2xl />
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<!-- Title area -->
|
||||
<div>
|
||||
<slot name="title">
|
||||
<h1 relative>
|
||||
<div v-if="effectiveCategoryLabel" absolute left-0 top-0 translate-y="[-80%]">
|
||||
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ effectiveCategoryLabel }}</span>
|
||||
</div>
|
||||
<div text-nowrap text-3xl font-semibold>
|
||||
{{ effectiveTitle }}
|
||||
</div>
|
||||
</h1>
|
||||
</slot>
|
||||
|
||||
<slot name="subtitle">
|
||||
<div v-if="subtitle" text-sm text="neutral-500 dark:neutral-400">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Extra header content (right side) -->
|
||||
<slot name="headerExtra" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- Main content -->
|
||||
<slot />
|
||||
|
||||
<!-- Background icon -->
|
||||
<div text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
|
||||
<div text="40" :class="providerIcon || providerIconColor" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { VoiceInfo } from '../../stores'
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { FieldCheckbox, TestDummyMarker } from '../index'
|
||||
|
||||
const props = defineProps<{
|
||||
// Input fields
|
||||
defaultText?: string
|
||||
availableVoices: VoiceInfo[]
|
||||
availableLanguages: string[]
|
||||
|
||||
// Provider-specific handlers (provided from parent)
|
||||
generateSpeech: (input: string, voice: string, useSSML: boolean) => Promise<ArrayBuffer>
|
||||
|
||||
// Current state
|
||||
apiKeyConfigured?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Playground state
|
||||
const testText = ref(props.defaultText || 'Hello! This is a test of the voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
const useSSML = ref(false)
|
||||
const ssmlText = ref('')
|
||||
const selectedLanguage = ref(props.availableLanguages[0] || 'en-US')
|
||||
const selectedVoice = ref('')
|
||||
|
||||
// Watch for changes in available voices
|
||||
watch(
|
||||
() => props.availableVoices,
|
||||
(newVoices) => {
|
||||
if (newVoices.length > 0 && !selectedVoice.value) {
|
||||
selectedVoice.value = newVoices[0]?.id || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Function to generate speech
|
||||
async function handleGenerateTestSpeech() {
|
||||
if ((!testText.value.trim() && !useSSML.value) || (useSSML.value && !ssmlText.value.trim()) || !selectedVoice.value)
|
||||
return
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
const input = useSSML.value ? ssmlText.value : testText.value
|
||||
|
||||
const response = await props.generateSpeech(input, selectedVoice.value, useSSML.value)
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
// Expose public methods and state
|
||||
defineExpose({
|
||||
testText,
|
||||
ssmlText,
|
||||
useSSML,
|
||||
selectedLanguage,
|
||||
selectedVoice,
|
||||
isGenerating,
|
||||
audioUrl,
|
||||
errorMessage,
|
||||
audioPlayer,
|
||||
generateTestSpeech: handleGenerateTestSpeech,
|
||||
stopTestAudio,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 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 v-for="voice in availableVoices" :key="voice.id" :value="voice.id">
|
||||
{{ voice.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Playground actions -->
|
||||
<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()) || !selectedVoice || !apiKeyConfigured"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !selectedVoice || !apiKeyConfigured }"
|
||||
bg="neutral-700 dark:neutral-300" @click="handleGenerateTestSpeech"
|
||||
>
|
||||
<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>
|
||||
<!-- Error messages -->
|
||||
<div v-if="!apiKeyConfigured" 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>
|
||||
<!-- Slot for additional provider-specific UI in the playground -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
|
||||
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'
|
||||
|
||||
import { useProvidersStore, useSpeechStore } from '../../stores'
|
||||
import {
|
||||
ProviderAdvancedSettings,
|
||||
ProviderApiKeyInput,
|
||||
ProviderBaseUrlInput,
|
||||
ProviderBasicSettings,
|
||||
ProviderSettingsContainer,
|
||||
} from '../index'
|
||||
import ProviderSettingsLayout2 from './ProviderSettingsLayout2.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
providerId: string
|
||||
// Default model to use if not specified in provider settings
|
||||
defaultModel?: string
|
||||
// Additional provider-specific settings
|
||||
additionalSettings?: Record<string, any>
|
||||
}>()
|
||||
|
||||
// Expose slots and emit events to allow customization
|
||||
defineSlots<{
|
||||
'basic-settings': (props: any) => any
|
||||
'voice-settings': (props: {
|
||||
voiceSettings: Record<string, any>
|
||||
updateVoiceSettings: (key: string, value: any) => void
|
||||
}) => any
|
||||
'advanced-settings': (props: any) => any
|
||||
'playground': (props: {
|
||||
isGenerating: boolean
|
||||
testText: string
|
||||
useSSML: boolean
|
||||
ssmlText: string
|
||||
generateTestSpeech: () => Promise<void>
|
||||
stopTestAudio: () => void
|
||||
audioUrl: string
|
||||
audioPlayer: HTMLAudioElement | null
|
||||
errorMessage: string
|
||||
}) => any
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const { providers } = storeToRefs(providersStore)
|
||||
|
||||
// Get provider metadata
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(props.providerId))
|
||||
|
||||
// Common provider settings
|
||||
const apiKey = computed({
|
||||
get: () => providers.value[props.providerId]?.apiKey as string | undefined || '',
|
||||
set: (value) => {
|
||||
if (!providers.value[props.providerId])
|
||||
providers.value[props.providerId] = {}
|
||||
|
||||
providers.value[props.providerId].apiKey = value
|
||||
},
|
||||
})
|
||||
|
||||
const baseUrl = computed({
|
||||
get: () => providers.value[props.providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || '',
|
||||
set: (value) => {
|
||||
if (!providers.value[props.providerId])
|
||||
providers.value[props.providerId] = {}
|
||||
|
||||
providers.value[props.providerId].baseUrl = value
|
||||
},
|
||||
})
|
||||
|
||||
// For playground
|
||||
const testText = ref('Hello! This is a test of voice synthesis.')
|
||||
const isGenerating = ref(false)
|
||||
const audioUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
const useSSML = ref(false)
|
||||
const ssmlText = ref('')
|
||||
const activeSpeechVoice = ref('')
|
||||
|
||||
// Voice settings as reactive objects to allow for different provider settings
|
||||
const voiceSettings = ref<Record<string, any>>({})
|
||||
|
||||
// Initialize voice settings with defaults or from provider
|
||||
function initializeVoiceSettings() {
|
||||
if (providers.value[props.providerId]?.voiceSettings) {
|
||||
voiceSettings.value = { ...(providers.value[props.providerId].voiceSettings as Record<string, any> | undefined) }
|
||||
}
|
||||
else {
|
||||
// Default values that most providers use
|
||||
voiceSettings.value = {
|
||||
pitch: 0,
|
||||
speed: 1.0,
|
||||
volume: 0,
|
||||
// Provider-specific defaults can be set in the onMounted lifecycle
|
||||
...props.additionalSettings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
providersStore.initializeProvider(props.providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[props.providerId]?.apiKey as string | undefined || ''
|
||||
baseUrl.value = providers.value[props.providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultOptions?.baseUrl as string | undefined || ''
|
||||
|
||||
// Initialize voice settings
|
||||
initializeVoiceSettings()
|
||||
|
||||
// Load voices if provider is configured
|
||||
if (providersStore.configuredProviders[props.providerId]) {
|
||||
speechStore.loadVoicesForProvider(props.providerId)
|
||||
}
|
||||
})
|
||||
|
||||
const debouncedUpdate = useDebounceFn(() => {
|
||||
providers.value[props.providerId] = {
|
||||
...providers.value[props.providerId],
|
||||
apiKey: apiKey.value,
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultOptions?.baseUrl || '',
|
||||
voiceSettings: { ...voiceSettings.value },
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Watch all settings and update the provider configuration
|
||||
watch([apiKey, baseUrl], debouncedUpdate)
|
||||
|
||||
// Watch voice settings for changes
|
||||
watch(voiceSettings, debouncedUpdate, { deep: true })
|
||||
|
||||
// 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(props.providerId) as SpeechProviderWithExtraOptions<string, any>
|
||||
if (!provider) {
|
||||
console.error('Failed to initialize speech provider')
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeSpeechVoice.value) {
|
||||
console.error('No active speech voice selected')
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Stop any currently playing audio
|
||||
if (audioUrl.value) {
|
||||
stopTestAudio()
|
||||
}
|
||||
|
||||
// Get the appropriate model (default or from provider settings)
|
||||
const modelToUse = props.defaultModel || 'default'
|
||||
|
||||
const input = useSSML.value
|
||||
? ssmlText.value
|
||||
: testText.value
|
||||
|
||||
const response = await generateSpeech({
|
||||
...provider.speech(modelToUse, {
|
||||
voiceSettings: voiceSettings.value,
|
||||
}),
|
||||
input,
|
||||
voice: activeSpeechVoice.value,
|
||||
})
|
||||
|
||||
// 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() {
|
||||
voiceSettings.value = { ...(providerMetadata.value?.defaultOptions?.voiceSettings || {}) }
|
||||
debouncedUpdate()
|
||||
}
|
||||
|
||||
// Helper function to update a specific voice setting
|
||||
function updateVoiceSetting(key: string, value: any) {
|
||||
voiceSettings.value[key] = value
|
||||
}
|
||||
|
||||
// Expose provider-specific data for slot usage
|
||||
const slotData = computed(() => ({
|
||||
voiceSettings: voiceSettings.value,
|
||||
updateVoiceSettings: updateVoiceSetting,
|
||||
isGenerating: isGenerating.value,
|
||||
testText: testText.value,
|
||||
useSSML: useSSML.value,
|
||||
ssmlText: ssmlText.value,
|
||||
generateTestSpeech,
|
||||
stopTestAudio,
|
||||
audioUrl: audioUrl.value,
|
||||
audioPlayer: audioPlayer.value,
|
||||
errorMessage: errorMessage.value,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProviderSettingsLayout2
|
||||
: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%]">
|
||||
<!-- Basic settings section -->
|
||||
<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-" />
|
||||
<!-- Slot for provider-specific basic settings -->
|
||||
<slot name="basic-settings" />
|
||||
</ProviderBasicSettings>
|
||||
|
||||
<!-- Voice settings section -->
|
||||
<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">
|
||||
<!-- Common voice settings with ranges -->
|
||||
<slot name="voice-settings" v-bind="slotData" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced settings section -->
|
||||
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
|
||||
<ProviderBaseUrlInput
|
||||
v-model="baseUrl"
|
||||
:placeholder="providerMetadata?.defaultOptions?.baseUrl as string || ''" required
|
||||
/>
|
||||
<!-- Slot for provider-specific advanced settings -->
|
||||
<slot name="advanced-settings" />
|
||||
</ProviderAdvancedSettings>
|
||||
</ProviderSettingsContainer>
|
||||
|
||||
<!-- Playground section -->
|
||||
<div flex="~ col gap-6" class="w-full md:w-[60%]">
|
||||
<div w-full rounded-xl>
|
||||
<!-- Custom playground slot -->
|
||||
<slot name="playground" v-bind="slotData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderSettingsLayout2>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { FieldCheckbox, FieldRange } from '../index'
|
||||
|
||||
defineProps<{
|
||||
settings: Record<string, any>
|
||||
// Which settings to show
|
||||
showPitch?: boolean
|
||||
showSpeed?: boolean
|
||||
showStyle?: boolean
|
||||
showStability?: boolean
|
||||
showSimilarityBoost?: boolean
|
||||
showVolume?: boolean
|
||||
showSpeakerBoost?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [key: string, value: any]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Define a function to update settings
|
||||
function updateSetting(key: string, value: any) {
|
||||
emit('update', key, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col gap-4">
|
||||
<!-- Pitch control - common to most providers -->
|
||||
<FieldRange
|
||||
v-if="showPitch"
|
||||
:model-value="settings.pitch ?? 0"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
|
||||
:min="-100"
|
||||
:max="100" :step="1" :format-value="value => `${value}%`"
|
||||
@update:model-value="value => updateSetting('pitch', value)"
|
||||
/>
|
||||
|
||||
<!-- Speed control - common to most providers -->
|
||||
<FieldRange
|
||||
v-if="showSpeed"
|
||||
:model-value="settings.speed ?? 1.0"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
|
||||
:min="0.5"
|
||||
:max="2.0" :step="0.01" @update:model-value="value => updateSetting('speed', value)"
|
||||
/>
|
||||
|
||||
<!-- Volume control - available in some providers -->
|
||||
<FieldRange
|
||||
v-if="showVolume"
|
||||
:model-value="settings.volume ?? 0"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
|
||||
:min="-100"
|
||||
:max="100" :step="1" :format-value="value => `${value}%`"
|
||||
@update:model-value="value => updateSetting('volume', value)"
|
||||
/>
|
||||
|
||||
<!-- Style control - specific to ElevenLabs -->
|
||||
<FieldRange
|
||||
v-if="showStyle"
|
||||
:model-value="settings.style ?? 0"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')"
|
||||
:min="0"
|
||||
:max="1" :step="0.01" @update:model-value="value => updateSetting('style', value)"
|
||||
/>
|
||||
|
||||
<!-- Stability control - specific to ElevenLabs -->
|
||||
<FieldRange
|
||||
v-if="showStability"
|
||||
:model-value="settings.stability ?? 0.5"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
|
||||
:min="0"
|
||||
:max="1" :step="0.01" @update:model-value="value => updateSetting('stability', value)"
|
||||
/>
|
||||
|
||||
<!-- Similarity Boost control - specific to ElevenLabs -->
|
||||
<FieldRange
|
||||
v-if="showSimilarityBoost"
|
||||
:model-value="settings.similarityBoost ?? 0.75"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
|
||||
:min="0"
|
||||
:max="1" :step="0.01" @update:model-value="value => updateSetting('similarityBoost', value)"
|
||||
/>
|
||||
|
||||
<!-- Speaker Boost checkbox - specific to ElevenLabs -->
|
||||
<FieldCheckbox
|
||||
v-if="showSpeakerBoost"
|
||||
:model-value="settings.useSpeakerBoost !== false"
|
||||
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
|
||||
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
|
||||
@update:model-value="value => updateSetting('useSpeakerBoost', value)"
|
||||
/>
|
||||
|
||||
<!-- Slot for additional provider-specific controls -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,4 +4,10 @@ export { default as ProviderApiKeyInput } from './ProviderApiKeyInput.vue'
|
||||
export { default as ProviderBaseUrlInput } from './ProviderBaseUrlInput.vue'
|
||||
export { default as ProviderBasicSettings } from './ProviderBasicSettings.vue'
|
||||
export { default as ProviderSettingsContainer } from './ProviderSettingsContainer.vue'
|
||||
export { default as ProviderSettingsLayout2 } from './ProviderSettingsLayout2.vue'
|
||||
export { default as ProviderSettingsLayout } from './ProviderSettingsLayout.vue'
|
||||
|
||||
export { default as SpeechPlayground } from './SpeechPlayground.vue'
|
||||
// New speech provider components
|
||||
export { default as SpeechProviderSettings } from './SpeechProviderSettings.vue'
|
||||
export { default as SpeechVoiceSettings } from './SpeechVoiceSettings.vue'
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
|
||||
import type { VoiceInfo } from '../providers'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { toXml } from 'xast-util-to-xml'
|
||||
import { x } from 'xastscript'
|
||||
|
||||
import { voiceList, voiceMap } from '../../constants/elevenlabs'
|
||||
import { useProvidersStore } from '../providers'
|
||||
@@ -129,19 +133,6 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Generate SSML from plain text and voice settings
|
||||
function generateSSML(text: string, voice: VoiceInfo): string {
|
||||
const pitchValue = pitch.value > 0 ? `+${pitch.value}%` : `${pitch.value}%`
|
||||
|
||||
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)
|
||||
@@ -154,6 +145,64 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate speech using the specified provider and settings
|
||||
*
|
||||
* @param provider The speech provider instance
|
||||
* @param model The model to use
|
||||
* @param input The text input to convert to speech
|
||||
* @param voice The voice ID to use
|
||||
* @param providerConfig Additional provider configuration
|
||||
* @returns ArrayBuffer containing the audio data
|
||||
*/
|
||||
async function speech(
|
||||
provider: SpeechProviderWithExtraOptions<string, any>,
|
||||
model: string,
|
||||
input: string,
|
||||
voice: string,
|
||||
providerConfig: Record<string, any> = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
const response = await generateSpeech({
|
||||
...provider.speech(model, {
|
||||
...providerConfig,
|
||||
}),
|
||||
input,
|
||||
voice,
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
function generateSSML(
|
||||
text: string,
|
||||
voice: VoiceInfo,
|
||||
pitch?: number,
|
||||
speed?: number,
|
||||
volume?: number,
|
||||
): string {
|
||||
const prosody = {
|
||||
pitch: pitch != null ? pitch > 0 ? `+${pitch}%` : `-${pitch}%` : undefined,
|
||||
rate: speed != null ? speed !== 1.0 ? `${speed}` : '1' : undefined,
|
||||
volume: volume != null ? volume > 0 ? `+${volume}%` : `${volume}%` : undefined,
|
||||
}
|
||||
|
||||
const ssmlXast = x('speak', { 'version': '1.0', 'xmlns': 'http://www.w3.org/2001/10/synthesis', 'xml:lang': voice.languages[0]?.code || 'en-US' }, [
|
||||
x('voice', { name: voice.id, gender: voice.gender || 'neutral' }, [
|
||||
Object.entries(prosody).filter(([_, value]) => value !== undefined).length > 0
|
||||
? x('prosody', {
|
||||
pitch: pitch != null ? pitch > 0 ? `+${pitch}%` : `-${pitch}%` : undefined,
|
||||
rate: speed != null ? speed !== 1.0 ? `${speed}` : '1' : undefined,
|
||||
volume: volume != null ? volume > 0 ? `+${volume}%` : `${volume}%` : undefined,
|
||||
}, [
|
||||
text,
|
||||
])
|
||||
: text,
|
||||
]),
|
||||
])
|
||||
|
||||
return toXml(ssmlXast)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activeSpeechProvider,
|
||||
@@ -180,6 +229,7 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
filteredModels,
|
||||
|
||||
// Actions
|
||||
speech,
|
||||
loadVoicesForProvider,
|
||||
getVoicesForProvider,
|
||||
generateSSML,
|
||||
|
||||
Generated
+44
-5
@@ -496,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))
|
||||
@@ -1114,6 +1114,15 @@ importers:
|
||||
reka-ui:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
|
||||
unist-builder:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
xast-util-to-xml:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
xastscript:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
devDependencies:
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.1
|
||||
@@ -4806,6 +4815,9 @@ packages:
|
||||
'@types/ws@8.5.13':
|
||||
resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==}
|
||||
|
||||
'@types/xast@2.0.4':
|
||||
resolution: {integrity: sha512-6Q6HWhHXR5EEKcxgF5YBW5XPAAtCi/GgyCWHx6wR7dZTXF5rv2B2fm0hgpSscJqaVDVm6n1DAVbsM8RSM5PlMw==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -11091,6 +11103,9 @@ packages:
|
||||
resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
unist-builder@4.0.0:
|
||||
resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==}
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
|
||||
|
||||
@@ -11823,6 +11838,12 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xast-util-to-xml@4.0.0:
|
||||
resolution: {integrity: sha512-r1euIWS5yZJUNpfN+ReE4m7Vld2iytaOPJtuXVuRQacRZwqRN1MAb+dv0aGLrdXOZrpGLyvUFf1Upyrb3R5qBg==}
|
||||
|
||||
xastscript@4.0.0:
|
||||
resolution: {integrity: sha512-r7a0kObEyivkML0dLrp/nOH5l51y9v5DL1MT/Xc6qUgGGNP1mZZUmT6NXtWAmx2FLfjonop++PtpVMwp1Hw/Gw==}
|
||||
|
||||
xml-name-validator@4.0.0:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -15608,6 +15629,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@types/xast@2.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.13.10
|
||||
@@ -23861,6 +23886,10 @@ snapshots:
|
||||
dependencies:
|
||||
crypto-random-string: 2.0.0
|
||||
|
||||
unist-builder@4.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.0
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.0
|
||||
@@ -23972,9 +24001,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)
|
||||
@@ -24033,7 +24062,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))
|
||||
@@ -24065,7 +24094,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:
|
||||
@@ -24846,6 +24875,16 @@ snapshots:
|
||||
bufferutil: 4.0.9
|
||||
utf-8-validate: 5.0.10
|
||||
|
||||
xast-util-to-xml@4.0.0:
|
||||
dependencies:
|
||||
'@types/xast': 2.0.4
|
||||
ccount: 2.0.1
|
||||
stringify-entities: 4.0.4
|
||||
|
||||
xastscript@4.0.0:
|
||||
dependencies:
|
||||
'@types/xast': 2.0.4
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user