mirror of
https://github.com/moeru-ai/airi.git
synced 2026-09-01 14:56:52 +08:00
feat(provider): add voicevox family speech providers (#2383)
## Description Adds the VOICEVOX engine family as text-to-speech providers. Two catalogue entries share one adapter: - `voicevox` — VOICEVOX ENGINE, default `http://localhost:50021/` - `aivis-speech` — AivisSpeech Engine, default `http://localhost:10101/` <img width="1203" height="976" alt="Screenshot 2026-08-28 at 23 33 30" src="https://github.com/user-attachments/assets/ae475852-ccc0-4bd7-81ea-9e9ba2f75e31" /> <img width="1203" height="976" alt="Screenshot 2026-08-28 at 23 34 14" src="https://github.com/user-attachments/assets/f83f73a9-ee76-4a91-a2fc-907b361beb0d" /> ## Linked Issues Closes #2166
This commit is contained in:
@@ -9,6 +9,7 @@ words:
|
||||
- aihubmix
|
||||
- airi
|
||||
- airi-vtuber
|
||||
- aivis
|
||||
- Alaya
|
||||
- alexanderolsen
|
||||
- alibabacloud
|
||||
@@ -135,6 +136,7 @@ words:
|
||||
- hfup
|
||||
- highpass
|
||||
- higress
|
||||
- hiroshiba
|
||||
- histoire
|
||||
- hiyori
|
||||
- Holo
|
||||
@@ -213,6 +215,7 @@ words:
|
||||
- Neko
|
||||
- nekomeowww
|
||||
- nekopaw
|
||||
- nemo
|
||||
- neuri
|
||||
- Neuro
|
||||
- Neuro-sama
|
||||
@@ -331,6 +334,7 @@ words:
|
||||
- unbird
|
||||
- unbundle
|
||||
- unconfig
|
||||
- unconfigured
|
||||
- uncrypto
|
||||
- unhead
|
||||
- Unlisten
|
||||
@@ -338,6 +342,7 @@ words:
|
||||
- unplugin
|
||||
- unref
|
||||
- unspeech
|
||||
- unstub
|
||||
- upterm
|
||||
- userversion
|
||||
- valibot
|
||||
@@ -348,6 +353,7 @@ words:
|
||||
- VITE
|
||||
- vitepress
|
||||
- vllm
|
||||
- Voicevox
|
||||
- Volcengine
|
||||
- vrma
|
||||
- vrms
|
||||
|
||||
@@ -1161,6 +1161,26 @@ pages:
|
||||
volume:
|
||||
description: Adjust the volume of speech
|
||||
label: Volume
|
||||
voicevox:
|
||||
description: voicevox.hiroshiba.jp
|
||||
title: VOICEVOX
|
||||
fields:
|
||||
field:
|
||||
intonation:
|
||||
description: Adjust how much the intonation of the reading varies
|
||||
label: Intonation
|
||||
callout_origin_title: Reaching the engine from a browser
|
||||
callout_origin: A browser reaches an engine on this machine only. For an engine on another machine, use the desktop application. The engine also has to accept this site, which you set with --cors_policy_mode all, or with --allow_origin and this address.
|
||||
aivis-speech:
|
||||
description: aivis-project.com
|
||||
title: AivisSpeech
|
||||
fields:
|
||||
field:
|
||||
intonation:
|
||||
description: Adjust how strongly the speaking style is expressed
|
||||
label: Emotion expression strength
|
||||
callout_origin_title: Reaching the engine from a browser
|
||||
callout_origin: A browser reaches an engine on this machine only. For an engine on another machine, use the desktop application. The engine also has to accept this site, which you set on its settings page on port 10101.
|
||||
deepgram-tts:
|
||||
description: deepgram.com
|
||||
title: Deepgram
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { VoicevoxFamilySettings } from '@proj-airi/stage-ui/components'
|
||||
|
||||
const providerId = 'aivis-speech'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VoicevoxFamilySettings
|
||||
:provider-id="providerId"
|
||||
intonation-label-key="settings.pages.providers.provider.aivis-speech.fields.field.intonation.label"
|
||||
intonation-description-key="settings.pages.providers.provider.aivis-speech.fields.field.intonation.description"
|
||||
default-text="こんにちは、はじめまして。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { VoicevoxFamilySettings } from '@proj-airi/stage-ui/components'
|
||||
|
||||
const providerId = 'voicevox'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VoicevoxFamilySettings
|
||||
:provider-id="providerId"
|
||||
intonation-label-key="settings.pages.providers.provider.voicevox.fields.field.intonation.label"
|
||||
intonation-description-key="settings.pages.providers.provider.voicevox.fields.field.intonation.description"
|
||||
default-text="こんにちは、はじめまして。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -13,3 +13,5 @@ export { default as SpeechProviderSettings } from './speech-provider-settings.vu
|
||||
|
||||
export { default as TranscriptionPlayground } from './transcription-playground.vue'
|
||||
export { default as TranscriptionProviderSettings } from './transcription-provider-settings.vue'
|
||||
|
||||
export { default as VoicevoxFamilySettings } from './voicevox-family-settings.vue'
|
||||
|
||||
@@ -26,12 +26,20 @@ const props = defineProps<{
|
||||
// Additional provider-specific settings
|
||||
additionalSettings?: Record<string, any>
|
||||
placeholder?: string
|
||||
// Hides the API key field for a provider that takes no credentials, such as a
|
||||
// local speech engine. The page otherwise shows a credential box with no effect.
|
||||
hideApiKey?: boolean
|
||||
}>()
|
||||
|
||||
// Expose slots and emit events to allow customization
|
||||
defineSlots<{
|
||||
'basic-settings': (props: any) => any
|
||||
'voice-settings': (props: any) => any
|
||||
/**
|
||||
* Receives the settings object this component owns and persists. Bind the
|
||||
* controls to its fields. Controls bound to local refs move on screen and
|
||||
* never reach the provider configuration.
|
||||
*/
|
||||
'voice-settings': (props: { voiceSettings: Record<string, any> }) => any
|
||||
'advanced-settings': (props: any) => any
|
||||
'playground': (props: any) => any
|
||||
}>()
|
||||
@@ -71,28 +79,42 @@ const baseUrl = computed({
|
||||
// Voice settings as reactive objects to allow for different provider settings
|
||||
const voiceSettings = ref<Record<string, any>>({})
|
||||
|
||||
/**
|
||||
* Resolves the voice settings a provider starts from.
|
||||
*
|
||||
* Three sources contribute, each overriding the one before it: the values most
|
||||
* speech providers share, the defaults the provider schema declares, and the
|
||||
* page-level overrides. A provider schema declares a different key set from the
|
||||
* shared values, so any source read alone drops keys.
|
||||
*
|
||||
* First load and Reset both resolve through here, so the two cannot disagree.
|
||||
*/
|
||||
function resolveDefaultVoiceSettings(): Record<string, any> {
|
||||
return {
|
||||
pitch: 0,
|
||||
speed: 1.0,
|
||||
volume: 0,
|
||||
...(providerMetadata.value?.defaultConfig.voiceSettings as Record<string, unknown> | undefined),
|
||||
...props.additionalSettings,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
const stored = providers.value[props.providerId]?.voiceSettings as Record<string, any> | undefined
|
||||
voiceSettings.value = stored ? { ...stored } : resolveDefaultVoiceSettings()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await providersStore.initializeProvider(props.providerId)
|
||||
|
||||
// Initialize refs with current values
|
||||
apiKey.value = providers.value[props.providerId]?.apiKey as string | undefined || ''
|
||||
// Skip the API key write when the field is hidden. Its setter mutates the
|
||||
// stored configuration, and an empty string makes that configuration differ
|
||||
// from the schema defaults. `shouldListProvider` reads any such difference as
|
||||
// the user having configured the provider.
|
||||
if (!props.hideApiKey)
|
||||
apiKey.value = providers.value[props.providerId]?.apiKey as string | undefined || ''
|
||||
|
||||
baseUrl.value = providers.value[props.providerId]?.baseUrl as string | undefined || providerMetadata.value?.defaultConfig.baseUrl as string | undefined || ''
|
||||
|
||||
// Initialize voice settings
|
||||
@@ -107,7 +129,9 @@ onMounted(async () => {
|
||||
const debouncedUpdate = useDebounceFn(() => {
|
||||
providers.value[props.providerId] = {
|
||||
...providers.value[props.providerId],
|
||||
apiKey: apiKey.value,
|
||||
// A provider without a credential field keeps no `apiKey` key. The guard in
|
||||
// `onMounted` stops the same key arriving by the other path.
|
||||
...(props.hideApiKey ? {} : { apiKey: apiKey.value }),
|
||||
baseUrl: baseUrl.value || providerMetadata.value?.defaultConfig.baseUrl || '',
|
||||
voiceSettings: { ...voiceSettings.value },
|
||||
}
|
||||
@@ -120,7 +144,7 @@ watch([apiKey, baseUrl], debouncedUpdate)
|
||||
watch(voiceSettings, debouncedUpdate, { deep: true })
|
||||
|
||||
function handleResetVoiceSettings() {
|
||||
voiceSettings.value = { ...(providerMetadata.value?.defaultConfig.voiceSettings as Record<string, unknown>) }
|
||||
voiceSettings.value = resolveDefaultVoiceSettings()
|
||||
debouncedUpdate()
|
||||
}
|
||||
</script>
|
||||
@@ -140,7 +164,7 @@ function handleResetVoiceSettings() {
|
||||
:description="t('settings.pages.providers.common.section.basic.description')"
|
||||
:on-reset="handleResetVoiceSettings"
|
||||
>
|
||||
<ProviderApiKeyInput v-model="apiKey" :provider-name="providerMetadata?.localizedName ?? ''" :placeholder="props.placeholder || 'API Key'" />
|
||||
<ProviderApiKeyInput v-if="!props.hideApiKey" v-model="apiKey" :provider-name="providerMetadata?.localizedName ?? ''" :placeholder="props.placeholder || 'API Key'" />
|
||||
<!-- Slot for provider-specific basic settings -->
|
||||
<slot name="basic-settings" />
|
||||
</ProviderBasicSettings>
|
||||
@@ -152,7 +176,7 @@ function handleResetVoiceSettings() {
|
||||
</h2>
|
||||
<div flex="~ col gap-4">
|
||||
<!-- Common voice settings with ranges -->
|
||||
<slot name="voice-settings" />
|
||||
<slot name="voice-settings" :voice-settings="voiceSettings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
import { PiniaColada } from '@pinia/colada'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import VoicevoxFamilySettings from './voicevox-family-settings.vue'
|
||||
|
||||
import { useSpeechStore } from '../../../stores/modules/speech'
|
||||
import { useProviderConfigStore } from '../../../stores/providers/config'
|
||||
|
||||
const ENGINE_SPEAKERS = [{
|
||||
name: 'ずんだもん',
|
||||
speaker_uuid: '388f246b-8c41-4ac1-8e2d-5d79f3ff56d9',
|
||||
styles: [{ id: 3, name: 'ノーマル', type: 'talk' }],
|
||||
}]
|
||||
|
||||
/** The page calls the engine over the ambient `fetch`, so that is the seam. */
|
||||
function stubReachableEngine() {
|
||||
vi.stubGlobal('fetch', async (input: URL | RequestInfo) => {
|
||||
const endpoint = new URL(String(input)).pathname.split('/').pop()
|
||||
if (endpoint === 'version')
|
||||
return new Response('"0.24.1"')
|
||||
if (endpoint === 'speakers')
|
||||
return Response.json(ENGINE_SPEAKERS)
|
||||
|
||||
throw new Error(`The settings page reached an unexpected endpoint: ${endpoint}`)
|
||||
})
|
||||
}
|
||||
|
||||
function stubUnreachableEngine() {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
}
|
||||
|
||||
async function mountSettings(pinia: Pinia) {
|
||||
const i18n = createI18n({
|
||||
// Every label resolves to its own key. These cases assert on engine data and
|
||||
// on store state, so they need no message catalogue.
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: {} },
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
return await render(VoicevoxFamilySettings, {
|
||||
props: {
|
||||
providerId: 'voicevox',
|
||||
intonationLabelKey: 'intonation.label',
|
||||
intonationDescriptionKey: 'intonation.description',
|
||||
defaultText: 'こんにちは',
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, PiniaColada, i18n, router],
|
||||
// The settings layout animates its header. The application installs the
|
||||
// motion plugin at its own entrypoint.
|
||||
directives: { motion: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('voicevox family settings', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The page left the provider at status `unconfigured`. The voice list stayed
|
||||
// empty, and the speech module offered no engine to select.
|
||||
//
|
||||
// No code validated these providers. `SpeechProviderSettings` loads the
|
||||
// catalogue only for a `configured` provider. The store reaches its
|
||||
// listed-provider sweep only through `resetProviderSettings`. An engine takes
|
||||
// no API key, so its configuration always equalled the schema defaults, and
|
||||
// `shouldListProvider` kept it out of that sweep.
|
||||
//
|
||||
// We fixed this by calling `useProviderValidation` from the settings page.
|
||||
it('loads the voice catalogue for a reachable engine', async () => {
|
||||
stubReachableEngine()
|
||||
const pinia = createPinia()
|
||||
|
||||
const screen = await mountSettings(pinia)
|
||||
const providerConfig = useProviderConfigStore(pinia)
|
||||
const speech = useSpeechStore(pinia)
|
||||
|
||||
await expect.poll(() => providerConfig.providers.voicevox?.status).toBe('configured')
|
||||
await expect.poll(() => speech.availableVoices.voicevox?.map(voice => voice.name)).toEqual(['ずんだもん / ノーマル'])
|
||||
await expect.element(screen.getByRole('button', { name: /Test/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('marks the provider invalid and loads no voices when the engine is unreachable', async () => {
|
||||
stubUnreachableEngine()
|
||||
const pinia = createPinia()
|
||||
|
||||
await mountSettings(pinia)
|
||||
const providerConfig = useProviderConfigStore(pinia)
|
||||
const speech = useSpeechStore(pinia)
|
||||
|
||||
await expect.poll(() => providerConfig.providers.voicevox?.status).toBe('invalid')
|
||||
expect(speech.availableVoices.voicevox).toBeUndefined()
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// A follower renderer reported "Failed to execute 'postMessage' on
|
||||
// 'BroadcastChannel': #<Object> could not be cloned."
|
||||
//
|
||||
// `validateProviderConfig` is a synchronized action. A follower posts its
|
||||
// arguments to the leader. The caller built them with `{ ...credentials.value }`,
|
||||
// which leaves every nested value a Vue reactive proxy. `structuredClone`
|
||||
// rejects a proxy. A flat credential pair survived that check, so only the
|
||||
// VOICEVOX family failed, through `voiceSettings`.
|
||||
//
|
||||
// We fixed this by taking a deep plain copy before the call.
|
||||
//
|
||||
// This case asserts the clone contract directly. A plain Pinia installs no
|
||||
// synchronization wrapper to post through.
|
||||
it('passes a structured-cloneable configuration to the synchronized validation action', async () => {
|
||||
stubReachableEngine()
|
||||
const pinia = createPinia()
|
||||
|
||||
// The provider store calls `useI18n`. It therefore exists only inside the
|
||||
// mounted application, where a Pinia plugin can observe its actions.
|
||||
const validationArgs: unknown[][] = []
|
||||
pinia.use(({ store }) => {
|
||||
if (store.$id !== 'provider')
|
||||
return
|
||||
|
||||
store.$onAction(({ args, name }) => {
|
||||
if (name === 'validateProviderConfig')
|
||||
validationArgs.push(args)
|
||||
})
|
||||
})
|
||||
|
||||
await mountSettings(pinia)
|
||||
await vi.waitFor(() => expect(validationArgs.length).toBeGreaterThan(0))
|
||||
|
||||
for (const args of validationArgs)
|
||||
expect(() => structuredClone(args)).not.toThrow()
|
||||
|
||||
// A deep copy must keep the nested options, not drop them.
|
||||
const config = validationArgs[0][1] as { voiceSettings?: unknown }
|
||||
expect(config.voiceSettings).toEqual({ intonation: 1, pitch: 0, speed: 1, volume: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { SpeechProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { Callout, FieldRange } from '@proj-airi/ui'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Alert from '../../misc/alert.vue'
|
||||
import SpeechPlayground from './speech-playground.vue'
|
||||
import SpeechProviderSettings from './speech-provider-settings.vue'
|
||||
|
||||
import { useProviderValidation } from '../../../composables/use-provider-validation'
|
||||
import { useSpeechStore } from '../../../stores/modules/speech'
|
||||
import { useProviderConfigStore } from '../../../stores/providers/config'
|
||||
import { useProviderStore } from '../../../stores/providers/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
providerId: string
|
||||
/** Translation key for the `intonationScale` label. Each engine reads that field differently. */
|
||||
intonationLabelKey: string
|
||||
intonationDescriptionKey: string
|
||||
defaultText: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
|
||||
// The engines take no credentials. The provider needs only the engine address,
|
||||
// which lives under Advanced settings.
|
||||
const apiKeyConfigured = true
|
||||
|
||||
// Only the browser surface negotiates the CORS policy of the engine. The
|
||||
// desktop application forwards its requests through the Electron main process,
|
||||
// which that policy does not restrict.
|
||||
const showOriginCallout = computed(() => !isStageTamagotchi())
|
||||
|
||||
const availableVoices = computed(() => speechStore.availableVoices[props.providerId] || [])
|
||||
|
||||
// This call is the only code that marks the provider `configured`. Every
|
||||
// voice-loading path reads that status.
|
||||
//
|
||||
// No other code validates these providers. An engine takes no API key, so the
|
||||
// stored configuration always equals the schema defaults. `shouldListProvider`
|
||||
// then keeps the provider out of the store validation sweep.
|
||||
const { forceValid, isValid, isValidating, validationMessage } = useProviderValidation(props.providerId)
|
||||
|
||||
// SpeechProviderSettings loads the catalogue on mount, but only for a
|
||||
// `configured` provider. The validation above resolves after that mount, so
|
||||
// this watcher is what fills the list.
|
||||
watch(() => providerStore.configuredProviders[props.providerId], async (configured) => {
|
||||
if (configured)
|
||||
await speechStore.loadVoicesForProvider(props.providerId)
|
||||
})
|
||||
|
||||
async function handleGenerateSpeech(input: string, voiceId: string) {
|
||||
const provider = await providersStore.getProviderInstance(props.providerId) as SpeechProvider
|
||||
if (!provider)
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
|
||||
return await speechStore.speech(
|
||||
provider,
|
||||
'default',
|
||||
input,
|
||||
voiceId,
|
||||
{ ...providerStore.getProviderConfig(props.providerId) },
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Callout
|
||||
v-if="showOriginCallout"
|
||||
theme="violet"
|
||||
:label="t(`settings.pages.providers.provider.${props.providerId}.callout_origin_title`)"
|
||||
>
|
||||
{{ t(`settings.pages.providers.provider.${props.providerId}.callout_origin`) }}
|
||||
</Callout>
|
||||
|
||||
<SpeechProviderSettings :provider-id="props.providerId" default-model="default" hide-api-key>
|
||||
<!-- The Advanced section starts collapsed. An unreachable engine leaves the
|
||||
voice list empty, so its reason has to stay visible. -->
|
||||
<template #basic-settings>
|
||||
<Alert v-if="isValidating > 0" type="loading">
|
||||
<template #title>
|
||||
{{ t('settings.dialogs.onboarding.validationRunning') }}
|
||||
</template>
|
||||
</Alert>
|
||||
<Alert v-else-if="!isValid && validationMessage" type="error">
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
|
||||
@click="forceValid"
|
||||
>
|
||||
{{ t('settings.pages.providers.common.continueAnyway') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="whitespace-pre-wrap break-all">
|
||||
{{ validationMessage }}
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
<Alert v-else-if="isValid" type="success">
|
||||
<template #title>
|
||||
{{ t('settings.dialogs.onboarding.validationSuccess') }}
|
||||
</template>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<template #voice-settings="{ voiceSettings }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<FieldRange
|
||||
v-model="voiceSettings.speed"
|
||||
: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" :step="0.01"
|
||||
/>
|
||||
|
||||
<!-- The engine reads pitchScale in a narrow band around zero. The cloud
|
||||
providers take a percentage instead. -->
|
||||
<FieldRange
|
||||
v-model="voiceSettings.pitch"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
|
||||
:min="-0.15" :max="0.15" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="voiceSettings.intonation"
|
||||
:label="t(props.intonationLabelKey)"
|
||||
:description="t(props.intonationDescriptionKey)"
|
||||
:min="0" :max="2" :step="0.01"
|
||||
/>
|
||||
|
||||
<FieldRange
|
||||
v-model="voiceSettings.volume"
|
||||
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
|
||||
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
|
||||
:min="0" :max="2" :step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="apiKeyConfigured"
|
||||
:use-ssml="false"
|
||||
:default-text="props.defaultText"
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import type { ProviderMode } from './use-analytics'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { computedAsync, useDebounceFn } from '@vueuse/core'
|
||||
import { cloneDeep } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -97,6 +98,23 @@ export function useProviderValidation(providerId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `validateProviderConfig` is a synchronized action. A follower renderer posts
|
||||
* its arguments over a BroadcastChannel.
|
||||
*
|
||||
* `structuredClone` rejects a Vue reactive proxy. A shallow copy keeps the
|
||||
* nested values as proxies, so this copy must be deep.
|
||||
*/
|
||||
function configToValidate(): Record<string, any> {
|
||||
const config = cloneDeep(credentials.value)
|
||||
if (config.apiKey)
|
||||
config.apiKey = config.apiKey.trim()
|
||||
if (config.baseUrl)
|
||||
config.baseUrl = config.baseUrl.trim()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
async function validateConfiguration() {
|
||||
if (!providerMetadata.value)
|
||||
return
|
||||
@@ -107,15 +125,9 @@ export function useProviderValidation(providerId: string) {
|
||||
let finalValidationMessage = ''
|
||||
|
||||
try {
|
||||
const config = { ...credentials.value }
|
||||
if (config.apiKey)
|
||||
config.apiKey = config.apiKey.trim()
|
||||
if (config.baseUrl)
|
||||
config.baseUrl = config.baseUrl.trim()
|
||||
|
||||
// Settings pages always skip chat ping check during automatic validation
|
||||
// to avoid unexpected API billing. Users can trigger it manually.
|
||||
const validationResult = await providersStore.validateProviderConfig(providerId, config, {
|
||||
const validationResult = await providersStore.validateProviderConfig(providerId, configToValidate(), {
|
||||
skipChatPingCheck: true,
|
||||
})
|
||||
isValid.value = validationResult.valid
|
||||
@@ -158,13 +170,7 @@ export function useProviderValidation(providerId: string) {
|
||||
trackProviderConnectionTestStarted(providerConnectionTestAnalyticsBase())
|
||||
|
||||
try {
|
||||
const config = { ...credentials.value }
|
||||
if (config.apiKey)
|
||||
config.apiKey = config.apiKey.trim()
|
||||
if (config.baseUrl)
|
||||
config.baseUrl = config.baseUrl.trim()
|
||||
|
||||
const result = await providersStore.validateProviderConfig(providerId, config, {
|
||||
const result = await providersStore.validateProviderConfig(providerId, configToValidate(), {
|
||||
onlyChatPingCheck: true,
|
||||
})
|
||||
manualTestPassed.value = result.valid
|
||||
|
||||
@@ -39,6 +39,7 @@ const recommendedPaidCloud = {
|
||||
const providerAttributesById = {
|
||||
'302-ai': paidCloud,
|
||||
'aihubmix': paidCloud,
|
||||
'aivis-speech': freeLocal,
|
||||
'alibaba-cloud-model-studio': paidCloud,
|
||||
'aliyun-nls-transcription': paidCloud,
|
||||
'amazon-bedrock': paidCloud,
|
||||
@@ -98,6 +99,7 @@ const providerAttributesById = {
|
||||
'player2-speech': freeLocal,
|
||||
'speech-noop': false,
|
||||
'together-ai': paidCloud,
|
||||
'voicevox': freeLocal,
|
||||
'volcengine': paidCloud,
|
||||
'volcengine-coding-plan': paidCloud,
|
||||
'xai': paidCloud,
|
||||
|
||||
@@ -50,6 +50,7 @@ import './azure-ai-foundry'
|
||||
import './official'
|
||||
import './speech-noop'
|
||||
import './unspeech'
|
||||
import './voicevox'
|
||||
|
||||
export {
|
||||
getDefaultStreamingModel,
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('migrated provider definitions', () => {
|
||||
'player2-speech',
|
||||
'kokoro-local',
|
||||
'google-gemini-audio-speech',
|
||||
'voicevox',
|
||||
'aivis-speech',
|
||||
]
|
||||
|
||||
for (const providerId of providerIds)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { SpeechProvider } from '@xsai-ext/providers/utils'
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { providerAivisSpeech, providerVoicevox } from '.'
|
||||
import { getProviderValidationIntervalMs } from '../../validators/run'
|
||||
|
||||
const translate = ((key: string) => key) as unknown as ComposerTranslation
|
||||
|
||||
interface EngineCall {
|
||||
init: RequestInit
|
||||
url: URL
|
||||
}
|
||||
|
||||
/** Answers the four endpoints over the ambient `fetch` the provider calls. */
|
||||
function installEngine(): EngineCall[] {
|
||||
const calls: EngineCall[] = []
|
||||
|
||||
vi.stubGlobal('fetch', async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
calls.push({ init: init ?? {}, url })
|
||||
|
||||
switch (url.pathname.split('/').pop()) {
|
||||
case 'audio_query':
|
||||
return Response.json({ intonationScale: 1, pitchScale: 0, speedScale: 1, volumeScale: 1 })
|
||||
case 'speakers':
|
||||
return Response.json([{ name: 'ずんだもん', speaker_uuid: 'b', styles: [{ id: 3, name: 'ノーマル' }] }])
|
||||
case 'synthesis':
|
||||
return new Response(new Uint8Array([82, 73, 70, 70]))
|
||||
case 'version':
|
||||
return new Response('"0.24.1"')
|
||||
default:
|
||||
throw new Error(`unexpected endpoint ${url.pathname}`)
|
||||
}
|
||||
})
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('vOICEVOX family definitions', () => {
|
||||
it('ships the documented default port for each engine', async () => {
|
||||
expect(z.parse(await providerVoicevox.createProviderConfig({ t: translate }), {}).baseUrl).toBe('http://localhost:50021/')
|
||||
expect(z.parse(await providerAivisSpeech.createProviderConfig({ t: translate }), {}).baseUrl).toBe('http://localhost:10101/')
|
||||
})
|
||||
|
||||
it('defaults volume and intonation to the engine neutral value, not to zero', async () => {
|
||||
// A zero `volumeScale` is silence. The shared settings component seeds
|
||||
// `{ pitch: 0, speed: 1, volume: 0 }` when a schema declares no defaults, so
|
||||
// a schema without these values makes a new provider synthesize nothing.
|
||||
const defaults = z.parse(await providerVoicevox.createProviderConfig({ t: translate }), {})
|
||||
|
||||
expect(defaults.voiceSettings).toEqual({ intonation: 1, pitch: 0, speed: 1, volume: 1 })
|
||||
})
|
||||
|
||||
it('declares the reachability schedule where the interval reader looks for it', async () => {
|
||||
// `getProviderValidationIntervalMs` reads only `validators.validateProvider`.
|
||||
// A schedule declared on `validateConfig` never runs, and reports no error.
|
||||
const intervalMs = await getProviderValidationIntervalMs({
|
||||
contextOptions: { t: translate },
|
||||
definition: providerVoicevox,
|
||||
})
|
||||
|
||||
expect(intervalMs).toBe(15_000)
|
||||
})
|
||||
|
||||
it('does not expose the chat ping checkbox, which has no meaning for an engine', async () => {
|
||||
const validators = await Promise.all((providerVoicevox.validators?.validateProvider ?? []).map(create => create({ t: translate })))
|
||||
|
||||
expect(validators).toHaveLength(1)
|
||||
expect(validators[0].id).toBe('voicevox:check-reachability')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vOICEVOX configuration validator', () => {
|
||||
it('names the engine default when the base URL is empty', async () => {
|
||||
const validator = await providerAivisSpeech.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator({ baseUrl: ' ' }, { t: translate })
|
||||
|
||||
expect(result?.valid).toBe(false)
|
||||
expect(result?.reason).toContain('http://localhost:10101/')
|
||||
})
|
||||
|
||||
it('rejects a base URL without a scheme', async () => {
|
||||
const validator = await providerVoicevox.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator({ baseUrl: 'localhost:50021' }, { t: translate })
|
||||
|
||||
expect(result?.valid).toBe(false)
|
||||
expect(result?.reason).toContain('Base URL is not absolute')
|
||||
})
|
||||
|
||||
it('rejects a scheme `fetch` cannot use, even though it parses', async () => {
|
||||
const validator = await providerVoicevox.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator({ baseUrl: 'ftp://engine.local/' }, { t: translate })
|
||||
|
||||
expect(result?.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a configured base URL', async () => {
|
||||
const validator = await providerVoicevox.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator({ baseUrl: 'http://localhost:50021/' }, { t: translate })
|
||||
|
||||
expect(result?.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vOICEVOX reachability validator', () => {
|
||||
it('passes when the engine answers its version', async () => {
|
||||
installEngine()
|
||||
const validator = await providerVoicevox.validators?.validateProvider?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator(
|
||||
{ baseUrl: 'http://localhost:50021/' },
|
||||
await providerVoicevox.createProvider({ baseUrl: 'http://localhost:50021/' }),
|
||||
{},
|
||||
{ t: translate },
|
||||
)
|
||||
|
||||
expect(result?.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('tells the user to check the port when the engine is down', async () => {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
throw new Error('Failed to fetch')
|
||||
})
|
||||
const validator = await providerVoicevox.validators?.validateProvider?.[0]({ t: translate })
|
||||
|
||||
const result = await validator?.validator(
|
||||
{ baseUrl: 'http://localhost:50021/' },
|
||||
await providerVoicevox.createProvider({ baseUrl: 'http://localhost:50021/' }),
|
||||
{},
|
||||
{ t: translate },
|
||||
)
|
||||
|
||||
expect(result?.valid).toBe(false)
|
||||
expect(result?.reason).toContain('Base URL matches its port')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vOICEVOX voice catalogue', () => {
|
||||
it('flattens every style of every character into one entry', async () => {
|
||||
installEngine()
|
||||
|
||||
const voices = await providerVoicevox.extraMethods?.listVoices?.(
|
||||
{ baseUrl: 'http://localhost:50021/' },
|
||||
await providerVoicevox.createProvider({ baseUrl: 'http://localhost:50021/' }),
|
||||
)
|
||||
|
||||
expect(voices).toHaveLength(1)
|
||||
expect(voices?.[0].id).toBe('3')
|
||||
expect(voices?.[0].name).toBe('ずんだもん / ノーマル')
|
||||
expect(voices?.[0].provider).toBe('voicevox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vOICEVOX speech provider', () => {
|
||||
it('reads input and voice out of the body that generateSpeech builds', async () => {
|
||||
// `requestBody` in `@xsai/shared` runs the options through `objCamelToSnake`,
|
||||
// so a key of more than one word arrives renamed. The adapter depends only
|
||||
// on the two single-word keys that survive that transform.
|
||||
const calls = installEngine()
|
||||
const provider = await providerVoicevox.createProvider({
|
||||
baseUrl: 'http://localhost:50021/',
|
||||
voiceSettings: { intonation: 1, pitch: 0, speed: 1.25, volume: 1 },
|
||||
}) as SpeechProvider
|
||||
|
||||
const audio = await generateSpeech({
|
||||
...provider.speech('default'),
|
||||
input: 'こんにちは',
|
||||
voice: '3',
|
||||
})
|
||||
|
||||
expect(Object.fromEntries(calls[0].url.searchParams)).toEqual({ speaker: '3', text: 'こんにちは' })
|
||||
expect(JSON.parse(String(calls[1].init.body))).toMatchObject({ speedScale: 1.25 })
|
||||
expect(Array.from(new Uint8Array(audio))).toEqual([82, 73, 70, 70])
|
||||
})
|
||||
|
||||
it('refuses to synthesize without a selected voice', async () => {
|
||||
installEngine()
|
||||
const provider = await providerVoicevox.createProvider({ baseUrl: 'http://localhost:50021/' }) as SpeechProvider
|
||||
|
||||
await expect(generateSpeech({ ...provider.speech('default'), input: 'あ', voice: '' }))
|
||||
.rejects
|
||||
.toThrow(/No voice selected/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { ProviderDefinition, VoiceInfo } from '../../types'
|
||||
import type { VoicevoxSynthesisParameters } from './engine'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
import { fetchEngineVersion, fetchSpeakers, synthesizeSpeech } from './engine'
|
||||
|
||||
/**
|
||||
* How long the reachability check waits before it calls the engine unreachable.
|
||||
*
|
||||
* A local engine answers `/version` at once after it starts. A longer wait only
|
||||
* delays the settings page.
|
||||
*/
|
||||
const REACHABILITY_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* How often the reachability check runs again. Ollama and LM Studio declare the
|
||||
* same interval.
|
||||
*
|
||||
* No code starts that loop. `startPeriodicRuntimeValidation` reads the schedule,
|
||||
* only `refreshListedProviderValidation` calls it, and only
|
||||
* `resetProviderSettings` calls that.
|
||||
*
|
||||
* The provider therefore reaches an engine that starts later only when the user
|
||||
* reopens the settings page, or edits the Base URL.
|
||||
*/
|
||||
const REACHABILITY_INTERVAL_MS = 15_000
|
||||
|
||||
/**
|
||||
* The engine has no model concept. The speech module treats an empty model
|
||||
* selection as unconfigured, so the catalogue publishes one entry with a stable
|
||||
* id. The user gets no choice between alternatives that do not exist.
|
||||
*/
|
||||
const SYNTHETIC_MODEL_ID = 'default'
|
||||
|
||||
/**
|
||||
* `generateSpeech` builds a URL from this before it calls the injected `fetch`.
|
||||
* The adapter ignores the URL, so the value only has to parse.
|
||||
*/
|
||||
const SENTINEL_BASE_URL = 'http://voicevox-family.invalid/v1/'
|
||||
|
||||
const voicevoxVoiceSettingsSchema = z.object({
|
||||
intonation: z.number().default(1),
|
||||
pitch: z.number().default(0),
|
||||
speed: z.number().default(1),
|
||||
volume: z.number().default(1),
|
||||
})
|
||||
|
||||
export type VoicevoxFamilyConfig = z.input<ReturnType<typeof createVoicevoxConfigSchema>>
|
||||
|
||||
export interface VoicevoxFamilyProviderOptions {
|
||||
/** Prefilled Base URL, and the address the validator names when the field is empty. */
|
||||
defaultBaseUrl: string
|
||||
/** Fallback description, shown when the locale has no entry. */
|
||||
description: string
|
||||
id: string
|
||||
/** Fallback name, shown when the locale has no entry. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one catalogue entry for an engine that implements the VOICEVOX HTTP API.
|
||||
*
|
||||
* `createProvider` returns an injected `fetch`, so synthesis never sends the
|
||||
* OpenAI-shaped request that `generateSpeech` builds. {@link synthesizeSpeech}
|
||||
* makes the two engine requests instead.
|
||||
*/
|
||||
export function defineVoicevoxFamilyProvider(options: VoicevoxFamilyProviderOptions): ProviderDefinition<VoicevoxFamilyConfig> {
|
||||
const configSchema = createVoicevoxConfigSchema(options.defaultBaseUrl)
|
||||
|
||||
return defineProvider<VoicevoxFamilyConfig>({
|
||||
createProvider(config) {
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: SENTINEL_BASE_URL,
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { input, voice } = readSpeechRequest(init)
|
||||
const wav = await synthesizeSpeech(
|
||||
config.baseUrl ?? options.defaultBaseUrl,
|
||||
{
|
||||
parameters: config.voiceSettings as undefined | VoicevoxSynthesisParameters,
|
||||
styleId: voice,
|
||||
text: input,
|
||||
},
|
||||
{ signal: init?.signal ?? undefined },
|
||||
)
|
||||
|
||||
return new Response(wav, { headers: { 'Content-Type': 'audio/wav' }, status: 200 })
|
||||
},
|
||||
model: SYNTHETIC_MODEL_ID,
|
||||
}),
|
||||
}
|
||||
},
|
||||
createProviderConfig: () => configSchema,
|
||||
description: options.description,
|
||||
descriptionLocalize: ({ t }) => t(`settings.pages.providers.provider.${options.id}.description`),
|
||||
extraMethods: {
|
||||
listModels: async () => [{
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
description: '',
|
||||
id: SYNTHETIC_MODEL_ID,
|
||||
name: options.name,
|
||||
provider: options.id,
|
||||
}],
|
||||
|
||||
listVoices: async (config) => {
|
||||
const speakers = await fetchSpeakers(config.baseUrl?.trim() ?? '')
|
||||
return speakers.flatMap(speaker => (speaker.styles ?? []).map(style => toVoiceInfo(options.id, speaker.name, style)))
|
||||
},
|
||||
},
|
||||
icon: 'i-lobe-icons:speaker',
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
|
||||
nameLocalize: ({ t }) => t(`settings.pages.providers.provider.${options.id}.title`),
|
||||
|
||||
tasks: ['text-to-speech'],
|
||||
|
||||
validationRequiredWhen: config => Boolean(config.baseUrl?.trim()),
|
||||
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: `${options.id}:check-config`,
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const reason = absoluteUrlError(config.baseUrl?.trim() ?? '', options.defaultBaseUrl)
|
||||
if (reason)
|
||||
return { errors: [{ error: new Error(reason) }], reason, reasonKey: '', valid: false }
|
||||
|
||||
return { errors: [], reason: '', reasonKey: '', valid: true }
|
||||
},
|
||||
}),
|
||||
],
|
||||
// The reachability probe belongs here, not in `validateConfig`.
|
||||
// `getProviderValidationIntervalMs` reads schedules only from
|
||||
// `validateProvider`. A schedule declared on `validateConfig` never runs.
|
||||
validateProvider: [
|
||||
({ t }) => ({
|
||||
id: `${options.id}:check-reachability`,
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
|
||||
schedule: {
|
||||
intervalMs: REACHABILITY_INTERVAL_MS,
|
||||
mode: 'interval',
|
||||
},
|
||||
validator: async (config) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS)
|
||||
try {
|
||||
await fetchEngineVersion(config.baseUrl?.trim() ?? '', { signal: controller.signal })
|
||||
return { errors: [], reason: '', reasonKey: '', valid: true }
|
||||
}
|
||||
catch (error) {
|
||||
const reason = `Cannot reach the speech engine: ${errorMessageFrom(error) ?? 'Unknown error'}\n\nMake sure the engine is running and that the Base URL matches its port.`
|
||||
return { errors: [{ error }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function absoluteUrlError(baseUrl: string, defaultBaseUrl: string) {
|
||||
if (!baseUrl)
|
||||
return `Base URL is required. Default to ${defaultBaseUrl} for this engine.`
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
// Check the scheme as well as the host. `ftp://engine/` parses and has a
|
||||
// host, so a host-only check accepts an address `fetch` cannot use, and then
|
||||
// asks the user for a scheme they already gave.
|
||||
if (!url.host || (url.protocol !== 'http:' && url.protocol !== 'https:'))
|
||||
return 'Base URL is not absolute. Try to include a scheme (http:// or https://).'
|
||||
}
|
||||
catch {
|
||||
return 'Base URL is not absolute. Try to include a scheme (http:// or https://).'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function createVoicevoxConfigSchema(defaultBaseUrl: string) {
|
||||
return z.object({
|
||||
baseUrl: z.string().default(defaultBaseUrl),
|
||||
voiceSettings: voicevoxVoiceSettingsSchema.default({ intonation: 1, pitch: 0, speed: 1, volume: 1 }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the segment text and the style id out of the OpenAI-shaped body that
|
||||
* `generateSpeech` builds.
|
||||
*
|
||||
* `requestBody` in `@xsai/shared` passes that object through `objCamelToSnake`.
|
||||
* Only single-word keys survive unchanged, and `input` and `voice` are two of
|
||||
* them. A key of more than one word arrives renamed. The synthesis parameters
|
||||
* therefore come from the provider configuration, not from this body.
|
||||
*/
|
||||
function readSpeechRequest(init: RequestInit | undefined): { input: string, voice: string } {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid speech request body')
|
||||
|
||||
const body = JSON.parse(init.body) as { input?: string, voice?: string }
|
||||
if (!body.voice)
|
||||
throw new Error('No voice selected. Pick a character in the speech settings.')
|
||||
|
||||
return { input: body.input ?? '', voice: body.voice }
|
||||
}
|
||||
|
||||
function toVoiceInfo(providerId: string, speakerName: string, style: { id: number, name: string }): VoiceInfo {
|
||||
return {
|
||||
id: String(style.id),
|
||||
languages: [{ code: 'ja', title: 'Japanese' }],
|
||||
name: `${speakerName} / ${style.name}`,
|
||||
provider: providerId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
applyVoicevoxParameters,
|
||||
fetchEngineVersion,
|
||||
fetchSpeakers,
|
||||
synthesizeSpeech,
|
||||
} from './engine'
|
||||
|
||||
const SPEAKERS = [
|
||||
{ name: 'ずんだもん', speaker_uuid: 'a', styles: [{ id: 3, name: 'ノーマル' }, { id: 1, name: 'あまあま' }] },
|
||||
{ name: '四国めたん', speaker_uuid: 'b', styles: [{ id: 2, name: 'ノーマル' }] },
|
||||
]
|
||||
|
||||
const AUDIO_QUERY = { accent_phrases: [], intonationScale: 1, pitchScale: 0, speedScale: 1, volumeScale: 1 }
|
||||
|
||||
interface EngineCall {
|
||||
init: RequestInit
|
||||
url: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers the four endpoints and records what reached them.
|
||||
*
|
||||
* @example
|
||||
* const engine = fakeEngine()
|
||||
* await fetchSpeakers('http://localhost:50021/', { fetch: engine.fetch })
|
||||
* engine.calls[0].url.pathname // => '/speakers'
|
||||
*/
|
||||
function fakeEngine(overrides: { synthesis?: () => Response } = {}) {
|
||||
const calls: EngineCall[] = []
|
||||
|
||||
const fetchImpl = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
calls.push({ init: init ?? {}, url })
|
||||
|
||||
switch (url.pathname.split('/').pop()) {
|
||||
case 'audio_query':
|
||||
return Response.json(AUDIO_QUERY)
|
||||
case 'speakers':
|
||||
return Response.json(SPEAKERS)
|
||||
case 'synthesis':
|
||||
return overrides.synthesis?.() ?? new Response(new Uint8Array([82, 73, 70, 70]))
|
||||
case 'version':
|
||||
return new Response('"0.24.1"')
|
||||
default:
|
||||
throw new Error(`unexpected endpoint ${url.pathname}`)
|
||||
}
|
||||
})
|
||||
|
||||
return { calls, fetch: fetchImpl as unknown as typeof globalThis.fetch }
|
||||
}
|
||||
|
||||
describe('engine request', () => {
|
||||
it('keeps a base URL path segment when the user omits the trailing slash', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await fetchSpeakers('http://example.internal/engine', { fetch: engine.fetch })
|
||||
|
||||
expect(engine.calls[0].url.href).toBe('http://example.internal/engine/speakers')
|
||||
})
|
||||
|
||||
it('resolves against the origin when the base URL has no path', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await fetchSpeakers(' http://localhost:50021 ', { fetch: engine.fetch })
|
||||
|
||||
expect(engine.calls[0].url.href).toBe('http://localhost:50021/speakers')
|
||||
})
|
||||
|
||||
it('gets the endpoints that read and posts the two that synthesize', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await fetchEngineVersion('http://localhost:50021/', { fetch: engine.fetch })
|
||||
await fetchSpeakers('http://localhost:50021/', { fetch: engine.fetch })
|
||||
await synthesizeSpeech('http://localhost:50021/', { styleId: '3', text: 'あ' }, { fetch: engine.fetch })
|
||||
|
||||
expect(engine.calls.map(call => call.init.method)).toEqual(['GET', 'GET', 'POST', 'POST'])
|
||||
})
|
||||
|
||||
it('sends no body for audio_query, whose text travels in the query string', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await synthesizeSpeech('http://localhost:50021/', { styleId: '3', text: 'こんにちは' }, { fetch: engine.fetch })
|
||||
|
||||
const [audioQuery] = engine.calls
|
||||
expect(audioQuery.init.body).toBeUndefined()
|
||||
expect(Object.fromEntries(audioQuery.url.searchParams)).toEqual({ speaker: '3', text: 'こんにちは' })
|
||||
})
|
||||
|
||||
it('sends the audio query back as a JSON body on synthesis', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await synthesizeSpeech('http://localhost:50021/', { styleId: '3', text: 'あ' }, { fetch: engine.fetch })
|
||||
|
||||
const synthesis = engine.calls[1]
|
||||
expect(synthesis.init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(synthesis.init.body))).toMatchObject(AUDIO_QUERY)
|
||||
expect(Object.fromEntries(synthesis.url.searchParams)).toEqual({ speaker: '3' })
|
||||
})
|
||||
|
||||
it('refuses redirects, because no engine in the family redirects', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await fetchSpeakers('http://localhost:50021/', { fetch: engine.fetch })
|
||||
|
||||
expect(engine.calls[0].init.redirect).toBe('error')
|
||||
})
|
||||
|
||||
it('reports a base URL it cannot resolve as an address problem, not as a parser error', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await expect(fetchSpeakers('', { fetch: engine.fetch }))
|
||||
.rejects
|
||||
.toThrow(/not an absolute http/)
|
||||
})
|
||||
|
||||
it('names the endpoint and the status when the engine rejects the request', async () => {
|
||||
const failing = vi.fn(async () => new Response('speaker not found', { status: 422, statusText: 'Unprocessable Entity' }))
|
||||
|
||||
await expect(fetchSpeakers('http://localhost:50021/', { fetch: failing as unknown as typeof globalThis.fetch }))
|
||||
.rejects
|
||||
.toThrow(/422 Unprocessable Entity for \/speakers: speaker not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('synthesizeSpeech', () => {
|
||||
it('calls audio_query and then synthesis, carrying the style id on both', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await synthesizeSpeech('http://localhost:50021/', { styleId: '3', text: 'あ' }, { fetch: engine.fetch })
|
||||
|
||||
expect(engine.calls.map(call => call.url.pathname)).toEqual(['/audio_query', '/synthesis'])
|
||||
expect(engine.calls.every(call => call.url.searchParams.get('speaker') === '3')).toBe(true)
|
||||
})
|
||||
|
||||
it('writes the four controls onto the audio query and leaves the rest alone', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
await synthesizeSpeech(
|
||||
'http://localhost:50021/',
|
||||
{ parameters: { intonation: 1.5, pitch: 0.05, speed: 1.25, volume: 0.8 }, styleId: '3', text: 'あ' },
|
||||
{ fetch: engine.fetch },
|
||||
)
|
||||
|
||||
expect(JSON.parse(String(engine.calls[1].init.body))).toEqual({
|
||||
accent_phrases: [],
|
||||
intonationScale: 1.5,
|
||||
pitchScale: 0.05,
|
||||
speedScale: 1.25,
|
||||
volumeScale: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the synthesis bytes unchanged', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
const wav = await synthesizeSpeech('http://localhost:50021/', { styleId: '3', text: 'あ' }, { fetch: engine.fetch })
|
||||
|
||||
expect(Array.from(new Uint8Array(wav))).toEqual([82, 73, 70, 70])
|
||||
})
|
||||
|
||||
it('forwards the abort signal so a cancelled turn stops mid synthesis', async () => {
|
||||
const engine = fakeEngine()
|
||||
const controller = new AbortController()
|
||||
|
||||
await synthesizeSpeech(
|
||||
'http://localhost:50021/',
|
||||
{ styleId: '3', text: 'あ' },
|
||||
{ fetch: engine.fetch, signal: controller.signal },
|
||||
)
|
||||
|
||||
expect(engine.calls.every(call => call.init.signal === controller.signal)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVoicevoxParameters', () => {
|
||||
it('keeps the engine value for a control the user never touched', () => {
|
||||
const audioQuery = { intonationScale: 1, pitchScale: 0, speedScale: 1, volumeScale: 1 }
|
||||
|
||||
applyVoicevoxParameters(audioQuery, { speed: 1.5 })
|
||||
|
||||
expect(audioQuery).toEqual({ intonationScale: 1, pitchScale: 0, speedScale: 1.5, volumeScale: 1 })
|
||||
})
|
||||
|
||||
it('writes a zero rather than treating it as absent', () => {
|
||||
const audioQuery = { intonationScale: 1, pitchScale: 0.1, speedScale: 1, volumeScale: 1 }
|
||||
|
||||
applyVoicevoxParameters(audioQuery, { pitch: 0 })
|
||||
|
||||
expect(audioQuery.pitchScale).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSpeakers', () => {
|
||||
it('returns the characters with their styles in engine order', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
const speakers = await fetchSpeakers('http://localhost:50021/', { fetch: engine.fetch })
|
||||
|
||||
expect(speakers.map(speaker => speaker.name)).toEqual(['ずんだもん', '四国めたん'])
|
||||
expect(speakers[0].styles.map(style => style.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('reports a body that is not JSON as a wrong Base URL rather than as a parse error', async () => {
|
||||
const html = vi.fn(async () => new Response('<!doctype html><html></html>'))
|
||||
|
||||
await expect(fetchSpeakers('http://localhost:8080/', { fetch: html as unknown as typeof globalThis.fetch }))
|
||||
.rejects
|
||||
.toThrow(/points at a VOICEVOX-compatible engine/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchEngineVersion', () => {
|
||||
it('strips the quotes of the bare JSON string the engine returns', async () => {
|
||||
const engine = fakeEngine()
|
||||
|
||||
expect(await fetchEngineVersion('http://localhost:50021/', { fetch: engine.fetch })).toBe('0.24.1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The HTTP contract of a VOICEVOX-family speech engine.
|
||||
*
|
||||
* The renderer calls the engine directly. An engine accepts a request that
|
||||
* carries no `Origin` header, which is what both the web page and the packaged
|
||||
* desktop renderer send to a local address.
|
||||
*/
|
||||
|
||||
/** The four endpoints this provider uses. A caller composes no path of its own. */
|
||||
type VoicevoxEngineEndpoint = 'audio_query' | 'speakers' | 'synthesis' | 'version'
|
||||
|
||||
const VOICEVOX_ENGINE_PATHS = {
|
||||
audio_query: 'audio_query',
|
||||
speakers: 'speakers',
|
||||
synthesis: 'synthesis',
|
||||
version: 'version',
|
||||
} as const satisfies Record<VoicevoxEngineEndpoint, string>
|
||||
|
||||
/** `audio_query` sends no body: its text and style id travel in the query string. */
|
||||
const POST_ENDPOINTS = new Set<VoicevoxEngineEndpoint>(['audio_query', 'synthesis'])
|
||||
|
||||
interface VoicevoxEngineRequest {
|
||||
baseUrl: string
|
||||
body?: unknown
|
||||
endpoint: VoicevoxEngineEndpoint
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface VoicevoxEngineRequestOptions {
|
||||
/** Injected in tests. Defaults to the ambient `fetch`. */
|
||||
fetch?: typeof globalThis.fetch
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthesis plan that `/audio_query` returns and `/synthesis` consumes.
|
||||
*
|
||||
* Only the four fields this provider writes are named. Every other field, such
|
||||
* as the accent phrases and the output format, goes back to `/synthesis`
|
||||
* unchanged, so the engine keeps its own defaults.
|
||||
*/
|
||||
export interface VoicevoxAudioQuery {
|
||||
[field: string]: unknown
|
||||
intonationScale: number
|
||||
pitchScale: number
|
||||
speedScale: number
|
||||
volumeScale: number
|
||||
}
|
||||
|
||||
/** One character returned by `GET /speakers`. */
|
||||
export interface VoicevoxSpeaker {
|
||||
name: string
|
||||
speaker_uuid?: string
|
||||
styles: VoicevoxSpeakerStyle[]
|
||||
}
|
||||
|
||||
/** One voice of one character. The `speaker` query parameter takes this `id`, not a character id. */
|
||||
export interface VoicevoxSpeakerStyle {
|
||||
id: number
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The four controls the settings page exposes.
|
||||
*
|
||||
* `intonation` reaches `intonationScale`. VOICEVOX reads that field as the
|
||||
* intonation, and AivisSpeech reads it as the strength of the emotion
|
||||
* expression. The wire field is the same, so only the label differs per engine.
|
||||
*/
|
||||
export interface VoicevoxSynthesisParameters {
|
||||
intonation?: number
|
||||
pitch?: number
|
||||
speed?: number
|
||||
volume?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the four controls onto the plan, in place.
|
||||
*
|
||||
* An absent control keeps the value the engine returned. A control set to zero
|
||||
* is written, so the check is the type and not the truthiness.
|
||||
*/
|
||||
export function applyVoicevoxParameters(
|
||||
audioQuery: VoicevoxAudioQuery,
|
||||
parameters: VoicevoxSynthesisParameters,
|
||||
): VoicevoxAudioQuery {
|
||||
if (typeof parameters.speed === 'number')
|
||||
audioQuery.speedScale = parameters.speed
|
||||
if (typeof parameters.pitch === 'number')
|
||||
audioQuery.pitchScale = parameters.pitch
|
||||
if (typeof parameters.intonation === 'number')
|
||||
audioQuery.intonationScale = parameters.intonation
|
||||
if (typeof parameters.volume === 'number')
|
||||
audioQuery.volumeScale = parameters.volume
|
||||
|
||||
return audioQuery
|
||||
}
|
||||
|
||||
export async function fetchEngineVersion(
|
||||
baseUrl: string,
|
||||
options?: VoicevoxEngineRequestOptions,
|
||||
): Promise<string> {
|
||||
const response = await request({ baseUrl, endpoint: 'version' }, options)
|
||||
// `/version` answers with a bare JSON string, so the quotes are part of the body.
|
||||
return (await response.text()).replace(/^"|"$/g, '')
|
||||
}
|
||||
|
||||
export async function fetchSpeakers(
|
||||
baseUrl: string,
|
||||
options?: VoicevoxEngineRequestOptions,
|
||||
): Promise<VoicevoxSpeaker[]> {
|
||||
const response = await request({ baseUrl, endpoint: 'speakers' }, options)
|
||||
const speakers = await decodeJson<VoicevoxSpeaker[]>(response, 'speakers')
|
||||
return Array.isArray(speakers) ? speakers : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns text into audio with two requests: `/audio_query`, then `/synthesis`.
|
||||
*
|
||||
* `/audio_query` takes the text as a query parameter, not as a body. The speech
|
||||
* pipeline passes one segment per call, so the URL stays short.
|
||||
*
|
||||
* @returns WAV bytes, at the sampling rate the engine is configured for.
|
||||
*/
|
||||
export async function synthesizeSpeech(
|
||||
baseUrl: string,
|
||||
synthesis: { parameters?: VoicevoxSynthesisParameters, styleId: string, text: string },
|
||||
options?: VoicevoxEngineRequestOptions,
|
||||
): Promise<ArrayBuffer> {
|
||||
const query = { speaker: synthesis.styleId, text: synthesis.text }
|
||||
const audioQueryResponse = await request({ baseUrl, endpoint: 'audio_query', query }, options)
|
||||
const audioQuery = applyVoicevoxParameters(
|
||||
await decodeJson<VoicevoxAudioQuery>(audioQueryResponse, 'audio_query'),
|
||||
synthesis.parameters ?? {},
|
||||
)
|
||||
|
||||
const synthesisResponse = await request(
|
||||
{ baseUrl, body: audioQuery, endpoint: 'synthesis', query: { speaker: synthesis.styleId } },
|
||||
options,
|
||||
)
|
||||
|
||||
return await synthesisResponse.arrayBuffer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a trailing slash so that a base URL with a path segment keeps it.
|
||||
*
|
||||
* @example
|
||||
* normalizeBaseUrl('http://localhost:50021')
|
||||
* // => 'http://localhost:50021/'
|
||||
*
|
||||
* @example
|
||||
* normalizeBaseUrl('http://example.internal/engine')
|
||||
* // => 'http://example.internal/engine/'
|
||||
*/
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim()
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`
|
||||
}
|
||||
|
||||
function buildUrl(engineRequest: VoicevoxEngineRequest): URL {
|
||||
const url = new URL(VOICEVOX_ENGINE_PATHS[engineRequest.endpoint], normalizeBaseUrl(engineRequest.baseUrl))
|
||||
for (const [key, value] of Object.entries(engineRequest.query ?? {}))
|
||||
url.searchParams.set(key, value)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
async function decodeJson<T>(response: Response, endpoint: string): Promise<T> {
|
||||
const body = await response.text()
|
||||
try {
|
||||
return JSON.parse(body) as T
|
||||
}
|
||||
catch {
|
||||
throw new Error(`Speech engine answered /${endpoint} with a body that is not JSON. Check that the Base URL points at a VOICEVOX-compatible engine.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function request(
|
||||
engineRequest: VoicevoxEngineRequest,
|
||||
options?: VoicevoxEngineRequestOptions,
|
||||
): Promise<Response> {
|
||||
let url: URL
|
||||
try {
|
||||
url = buildUrl(engineRequest)
|
||||
}
|
||||
catch {
|
||||
throw new Error('The Base URL is not an absolute http:// or https:// address.')
|
||||
}
|
||||
|
||||
const doFetch = options?.fetch ?? globalThis.fetch
|
||||
const response = await doFetch(url, {
|
||||
method: POST_ENDPOINTS.has(engineRequest.endpoint) ? 'POST' : 'GET',
|
||||
// No engine in the family redirects. A redirect therefore means the Base URL
|
||||
// points at something else, and following it would hide that.
|
||||
redirect: 'error',
|
||||
signal: options?.signal,
|
||||
...(engineRequest.body === undefined
|
||||
? {}
|
||||
: { body: JSON.stringify(engineRequest.body), headers: { 'Content-Type': 'application/json' } }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).trim()
|
||||
const suffix = detail ? `: ${detail.slice(0, 200)}` : ''
|
||||
throw new Error(`Speech engine answered ${response.status} ${response.statusText} for /${engineRequest.endpoint}${suffix}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineVoicevoxFamilyProvider } from './define'
|
||||
|
||||
export const providerVoicevox = defineVoicevoxFamilyProvider({
|
||||
defaultBaseUrl: 'http://localhost:50021/',
|
||||
description: 'voicevox.hiroshiba.jp',
|
||||
id: 'voicevox',
|
||||
name: 'VOICEVOX',
|
||||
})
|
||||
|
||||
export const providerAivisSpeech = defineVoicevoxFamilyProvider({
|
||||
defaultBaseUrl: 'http://localhost:10101/',
|
||||
description: 'aivis-project.com',
|
||||
id: 'aivis-speech',
|
||||
name: 'AivisSpeech',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, providerOfficialSpeech } from '../../libs/providers/providers/official'
|
||||
@@ -417,3 +417,91 @@ describe('speech store helpers', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('single model speech providers', () => {
|
||||
// Selecting a provider makes the speech store load its voices. Without a stub
|
||||
// the VOICEVOX entries reach for a real engine on localhost. The rejection
|
||||
// then logs after the file finishes, and the run fails on a teardown race.
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('fetch', async () => Response.json([]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2166
|
||||
it('selects the only published model, so the provider is not left unconfigured — Issue #2166', async () => {
|
||||
// `settings/modules/speech.vue` clears `activeSpeechModel` on every provider
|
||||
// switch. Without the seeding below, a provider that publishes one model
|
||||
// keeps an empty model, `configured` stays false, and the stage never
|
||||
// speaks until the user opens the dropdown and picks that one entry.
|
||||
const providersStore = useProviderStore()
|
||||
const speechStore = useSpeechStore()
|
||||
speechStore.activeSpeechProvider = 'voicevox'
|
||||
speechStore.activeSpeechModel = ''
|
||||
await providersStore.initializeProvider('voicevox')
|
||||
providersStore.providerRuntimeState.voicevox.models = [
|
||||
{ id: 'default', name: 'VOICEVOX', provider: 'voicevox' },
|
||||
]
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
|
||||
expect(speechStore.activeSpeechModel).toBe('default')
|
||||
})
|
||||
|
||||
it('keeps the voice when it seeds the model, because voices belong to the provider', async () => {
|
||||
const providersStore = useProviderStore()
|
||||
const speechStore = useSpeechStore()
|
||||
speechStore.activeSpeechProvider = 'voicevox'
|
||||
speechStore.activeSpeechModel = ''
|
||||
speechStore.activeSpeechVoiceId = '3'
|
||||
await providersStore.initializeProvider('voicevox')
|
||||
providersStore.providerRuntimeState.voicevox.models = [
|
||||
{ id: 'default', name: 'VOICEVOX', provider: 'voicevox' },
|
||||
]
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
|
||||
expect(speechStore.activeSpeechVoiceId).toBe('3')
|
||||
})
|
||||
|
||||
it('does not guess when a provider publishes several models', async () => {
|
||||
const providersStore = useProviderStore()
|
||||
const speechStore = useSpeechStore()
|
||||
speechStore.activeSpeechProvider = 'elevenlabs'
|
||||
speechStore.activeSpeechModel = ''
|
||||
await providersStore.initializeProvider('elevenlabs')
|
||||
providersStore.providerRuntimeState.elevenlabs.models = [
|
||||
{ id: 'eleven_v3', name: 'v3', provider: 'elevenlabs' },
|
||||
{ id: 'eleven_flash_v2_5', name: 'flash', provider: 'elevenlabs' },
|
||||
]
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
|
||||
expect(speechStore.activeSpeechModel).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vOICEVOX provider defaults', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2166
|
||||
it('persists the neutral volume default, so a new provider is not silent — Issue #2166', async () => {
|
||||
// The settings form seeds `{ pitch: 0, speed: 1, volume: 0 }` when the
|
||||
// stored configuration carries no voice settings, and a `volumeScale` of
|
||||
// zero is silence. Provider metadata resolves asynchronously, so this pins
|
||||
// that `initializeProvider` waits for it before it writes the schema
|
||||
// defaults into the stored configuration.
|
||||
const providersStore = useProviderStore()
|
||||
const providerConfigStore = useProviderConfigStore()
|
||||
|
||||
await providersStore.initializeProvider('voicevox')
|
||||
|
||||
expect(providerConfigStore.getProviderConfig('voicevox')?.voiceSettings)
|
||||
.toEqual({ speed: 1, pitch: 0, intonation: 1, volume: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,11 +183,32 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
clearVoiceSelection()
|
||||
}
|
||||
|
||||
// A provider that publishes one model publishes no choice. An empty selection
|
||||
// keeps `configured` false until the user opens the dropdown and picks that
|
||||
// one entry, and the provider looks broken until then. This applies to every
|
||||
// single-model speech provider, not only to the VOICEVOX family.
|
||||
//
|
||||
// The voice selection stays as it is. Voices belong to the provider, not to
|
||||
// this model, and a provider switch clears both before this runs.
|
||||
function ensureSingleOptionSpeechModel() {
|
||||
const models = providersStore.getModelsForProvider(activeSpeechProvider.value)
|
||||
if (models.length !== 1)
|
||||
return
|
||||
|
||||
const onlyModelId = models[0]?.id ?? ''
|
||||
if (!onlyModelId || activeSpeechModel.value === onlyModelId)
|
||||
return
|
||||
|
||||
activeSpeechModel.value = onlyModelId
|
||||
}
|
||||
|
||||
function ensureActiveSpeechModel() {
|
||||
ensureStreamingDefaultModel()
|
||||
|
||||
if (activeSpeechProvider.value !== OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
if (activeSpeechProvider.value !== OFFICIAL_SPEECH_PROVIDER_ID) {
|
||||
ensureSingleOptionSpeechModel()
|
||||
return
|
||||
}
|
||||
|
||||
const models = providersStore.getModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
if (!models.length)
|
||||
|
||||
Reference in New Issue
Block a user