feat(speech): enhance voice pack handling and add computed properties for UI logic
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
|||||||
import { useAnalytics } from '@proj-airi/stage-ui/composables'
|
import { useAnalytics } from '@proj-airi/stage-ui/composables'
|
||||||
import { OFFICIAL_SPEECH_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
|
import { OFFICIAL_SPEECH_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official'
|
||||||
import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores'
|
import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores'
|
||||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
import { useSpeechStore, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||||
import {
|
import {
|
||||||
FieldCheckbox,
|
FieldCheckbox,
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from '@proj-airi/ui'
|
} from '@proj-airi/ui'
|
||||||
import { generateSpeech } from '@xsai/generate-speech'
|
import { generateSpeech } from '@xsai/generate-speech'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
@@ -66,6 +66,12 @@ const audioUrl = ref('')
|
|||||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const supportsVoicePackSelection = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID)
|
||||||
|
const shouldShowVoicePackSection = computed(() =>
|
||||||
|
supportsVoicePackSelection.value
|
||||||
|
&& (isLoadingVoicePacks.value || voicePacksError.value != null || voicePacks.value.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
|
function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
|
||||||
return {
|
return {
|
||||||
id: voicePack.voiceId,
|
id: voicePack.voiceId,
|
||||||
@@ -186,7 +192,7 @@ async function generateTestSpeech() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const voicePack = activeCard.value?.extensions.airi.modules.speech.voicePack
|
const voicePack = voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack)
|
||||||
if (voicePack) {
|
if (voicePack) {
|
||||||
model = voicePack.ttsModelId
|
model = voicePack.ttsModelId
|
||||||
if (!voice || voice.id !== voicePack.voiceId)
|
if (!voice || voice.id !== voicePack.voiceId)
|
||||||
@@ -318,61 +324,57 @@ function handleDeleteProvider(providerId: string) {
|
|||||||
<div flex="~ col md:row gap-6">
|
<div flex="~ col md:row gap-6">
|
||||||
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
|
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
|
||||||
<div flex="~ col gap-4">
|
<div flex="~ col gap-4">
|
||||||
<div>
|
<template v-if="shouldShowVoicePackSection">
|
||||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
<div>
|
||||||
{{ t('settings.pages.modules.speech.sections.section.voice-pack.title') }}
|
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||||
</h2>
|
{{ t('settings.pages.modules.speech.sections.section.voice-pack.title') }}
|
||||||
<div text="neutral-400 dark:neutral-500">
|
</h2>
|
||||||
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.description') }}</span>
|
<div text="neutral-400 dark:neutral-500">
|
||||||
</div>
|
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.description') }}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoadingVoicePacks" :class="['flex items-center gap-2', 'text-sm text-neutral-400 dark:text-neutral-500']">
|
|
||||||
<div i-solar:spinner-line-duotone class="animate-spin text-base" />
|
|
||||||
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.loading') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ErrorContainer
|
|
||||||
v-else-if="voicePacksError"
|
|
||||||
:title="t('settings.pages.modules.speech.sections.section.voice-pack.error')"
|
|
||||||
:error="voicePacksError"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else-if="voicePacks.length > 0" :class="['grid grid-cols-1 gap-2']">
|
|
||||||
<button
|
|
||||||
v-for="pack in voicePacks"
|
|
||||||
:key="pack.id"
|
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'w-full border rounded-lg px-3 py-2 text-left transition-colors',
|
|
||||||
'border-neutral-200 bg-white hover:border-primary-400 dark:border-neutral-800 dark:bg-neutral-900/60 dark:hover:border-primary-500',
|
|
||||||
airiCardStore.activeCard?.extensions.airi.modules.speech.voicePack?.packId === pack.id
|
|
||||||
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-950/30'
|
|
||||||
: '',
|
|
||||||
]"
|
|
||||||
@click="bindVoicePack(pack)"
|
|
||||||
>
|
|
||||||
<div :class="['flex items-center justify-between gap-3']">
|
|
||||||
<div :class="['min-w-0']">
|
|
||||||
<div :class="['truncate text-sm font-medium text-neutral-700 dark:text-neutral-200']">
|
|
||||||
{{ pack.name }}
|
|
||||||
</div>
|
|
||||||
<div :class="['truncate text-xs text-neutral-400 dark:text-neutral-500']">
|
|
||||||
{{ pack.ttsModelId }} / {{ pack.voiceId }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span :class="['shrink-0 rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400']">
|
|
||||||
{{ formatCostMultiplier(pack.costMultiplier) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert v-else type="info" icon="i-solar:info-circle-line-duotone">
|
<div v-if="isLoadingVoicePacks" :class="['flex items-center gap-2', 'text-sm text-neutral-400 dark:text-neutral-500']">
|
||||||
<template #title>
|
<div i-solar:spinner-line-duotone class="animate-spin text-base" />
|
||||||
{{ t('settings.pages.modules.speech.sections.section.voice-pack.empty') }}
|
<span>{{ t('settings.pages.modules.speech.sections.section.voice-pack.loading') }}</span>
|
||||||
</template>
|
</div>
|
||||||
</Alert>
|
|
||||||
|
<ErrorContainer
|
||||||
|
v-else-if="voicePacksError"
|
||||||
|
:title="t('settings.pages.modules.speech.sections.section.voice-pack.error')"
|
||||||
|
:error="voicePacksError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="voicePacks.length > 0" :class="['grid grid-cols-1 gap-2']">
|
||||||
|
<button
|
||||||
|
v-for="pack in voicePacks"
|
||||||
|
:key="pack.id"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-left transition-colors',
|
||||||
|
'border-neutral-200 bg-white hover:border-primary-400 dark:border-neutral-800 dark:bg-neutral-900/60 dark:hover:border-primary-500',
|
||||||
|
airiCardStore.activeCard?.extensions.airi.modules.speech.voicePack?.packId === pack.id
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-950/30'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
@click="bindVoicePack(pack)"
|
||||||
|
>
|
||||||
|
<div :class="['flex items-center justify-between gap-3']">
|
||||||
|
<div :class="['min-w-0']">
|
||||||
|
<div :class="['truncate text-sm font-medium text-neutral-700 dark:text-neutral-200']">
|
||||||
|
{{ pack.name }}
|
||||||
|
</div>
|
||||||
|
<div :class="['truncate text-xs text-neutral-400 dark:text-neutral-500']">
|
||||||
|
{{ pack.ttsModelId }} / {{ pack.voiceId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span :class="['shrink-0 rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400']">
|
||||||
|
{{ formatCostMultiplier(pack.costMultiplier) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useBackgroundStore } from '../../stores/background'
|
|||||||
import { useChatOrchestratorStore } from '../../stores/chat'
|
import { useChatOrchestratorStore } from '../../stores/chat'
|
||||||
import { useLlmStreamingControlStore } from '../../stores/llm-streaming-control'
|
import { useLlmStreamingControlStore } from '../../stores/llm-streaming-control'
|
||||||
import { useAiriCardStore } from '../../stores/modules'
|
import { useAiriCardStore } from '../../stores/modules'
|
||||||
import { useSpeechStore } from '../../stores/modules/speech'
|
import { useSpeechStore, voicePackForSpeechProvider } from '../../stores/modules/speech'
|
||||||
import { useProvidersStore } from '../../stores/providers'
|
import { useProvidersStore } from '../../stores/providers'
|
||||||
import { useSettings } from '../../stores/settings'
|
import { useSettings } from '../../stores/settings'
|
||||||
import { useSpeechOutputControlStore } from '../../stores/speech-output-control'
|
import { useSpeechOutputControlStore } from '../../stores/speech-output-control'
|
||||||
@@ -424,7 +424,7 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const voicePack = activeCard.value?.extensions.airi.modules.speech.voicePack
|
const voicePack = voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack)
|
||||||
if (voicePack) {
|
if (voicePack) {
|
||||||
model = voicePack.ttsModelId
|
model = voicePack.ttsModelId
|
||||||
if (!voice || voice.id !== voicePack.voiceId)
|
if (!voice || voice.id !== voicePack.voiceId)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
|
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
|
||||||
import { useProvidersStore } from '../providers'
|
import { useProvidersStore } from '../providers'
|
||||||
import { toSignedPercent, useSpeechStore } from './speech'
|
import { toSignedPercent, useSpeechStore, voicePackForSpeechProvider } from './speech'
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
@@ -30,6 +30,50 @@ describe('speech store helpers', () => {
|
|||||||
expect(toSignedPercent(0)).toBe('0%')
|
expect(toSignedPercent(0)).toBe('0%')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* speechStore.resolveVoicePackSpeechInput({ text, voice, providerConfig: { voice: 'plain' } })
|
||||||
|
*/
|
||||||
|
it('leaves speech input unchanged when no Voice Pack is configured', () => {
|
||||||
|
const speechStore = useSpeechStore()
|
||||||
|
const voice = {
|
||||||
|
id: 'plain-voice',
|
||||||
|
name: 'Plain Voice',
|
||||||
|
provider: 'openai-compatible-audio-speech',
|
||||||
|
languages: [{ code: 'en-US', title: 'English' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = speechStore.resolveVoicePackSpeechInput({
|
||||||
|
text: 'hello',
|
||||||
|
voice,
|
||||||
|
providerConfig: { voice: 'plain-voice' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request.input).toBe('hello')
|
||||||
|
expect(request.providerConfig).toEqual({ voice: 'plain-voice' })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* voicePackForSpeechProvider('openai-compatible-audio-speech', voicePack)
|
||||||
|
*/
|
||||||
|
it('ignores Voice Pack snapshots for non-official speech providers', () => {
|
||||||
|
const voicePack = {
|
||||||
|
packId: 'vp-1',
|
||||||
|
name: 'Frozen',
|
||||||
|
provider: 'volcengine',
|
||||||
|
model: 'seed-tts-2.0',
|
||||||
|
voiceId: 'voice-a',
|
||||||
|
ttsModelId: 'volcengine/pool-a',
|
||||||
|
params: {},
|
||||||
|
costMultiplier: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(voicePackForSpeechProvider('openai-compatible-audio-speech', voicePack)).toBeUndefined()
|
||||||
|
expect(voicePackForSpeechProvider(OFFICIAL_SPEECH_PROVIDER_ID, voicePack)).toBe(voicePack)
|
||||||
|
expect(voicePackForSpeechProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, voicePack)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @example
|
* @example
|
||||||
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { rate: '+20%' } })
|
* speechStore.resolveVoicePackSpeechInput({ text, voice, params: { rate: '+20%' } })
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ interface VoicePackSpeechInput {
|
|||||||
|
|
||||||
const voicePackSupportedParams = new Set(['pitch', 'rate', 'volume'])
|
const voicePackSupportedParams = new Set(['pitch', 'rate', 'volume'])
|
||||||
|
|
||||||
|
export function voicePackForSpeechProvider(
|
||||||
|
providerId: string | undefined,
|
||||||
|
voicePack: VoicePackSnapshot | undefined,
|
||||||
|
): VoicePackSnapshot | undefined {
|
||||||
|
return providerId === OFFICIAL_SPEECH_PROVIDER_ID ? voicePack : undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a Voice Pack percent-style option.
|
* Normalizes a Voice Pack percent-style option.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user