diff --git a/cspell.config.yaml b/cspell.config.yaml
index 5afe4cfc5..a57b84687 100644
--- a/cspell.config.yaml
+++ b/cspell.config.yaml
@@ -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
diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml
index 3923a04a7..8561134d0 100644
--- a/packages/i18n/src/locales/en/settings.yaml
+++ b/packages/i18n/src/locales/en/settings.yaml
@@ -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
diff --git a/packages/stage-pages/src/pages/settings/providers/speech/aivis-speech.vue b/packages/stage-pages/src/pages/settings/providers/speech/aivis-speech.vue
new file mode 100644
index 000000000..ca420d622
--- /dev/null
+++ b/packages/stage-pages/src/pages/settings/providers/speech/aivis-speech.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ meta:
+ layout: settings
+ stageTransition:
+ name: slide
+
diff --git a/packages/stage-pages/src/pages/settings/providers/speech/voicevox.vue b/packages/stage-pages/src/pages/settings/providers/speech/voicevox.vue
new file mode 100644
index 000000000..e1c929675
--- /dev/null
+++ b/packages/stage-pages/src/pages/settings/providers/speech/voicevox.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ meta:
+ layout: settings
+ stageTransition:
+ name: slide
+
diff --git a/packages/stage-ui/src/components/scenarios/providers/index.ts b/packages/stage-ui/src/components/scenarios/providers/index.ts
index a0785398a..4ac1fb0c1 100644
--- a/packages/stage-ui/src/components/scenarios/providers/index.ts
+++ b/packages/stage-ui/src/components/scenarios/providers/index.ts
@@ -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'
diff --git a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue
index 3121a2c6d..c269face1 100644
--- a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue
+++ b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue
@@ -26,12 +26,20 @@ const props = defineProps<{
// Additional provider-specific settings
additionalSettings?: Record
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 }) => 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>({})
+/**
+ * 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 {
+ return {
+ pitch: 0,
+ speed: 1.0,
+ volume: 0,
+ ...(providerMetadata.value?.defaultConfig.voiceSettings as Record | 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 | 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 | 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) }
+ voiceSettings.value = resolveDefaultVoiceSettings()
debouncedUpdate()
}
@@ -140,7 +164,7 @@ function handleResetVoiceSettings() {
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetVoiceSettings"
>
-
+
@@ -152,7 +176,7 @@ function handleResetVoiceSettings() {
-
+
diff --git a/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts
new file mode 100644
index 000000000..5d6d68e3e
--- /dev/null
+++ b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts
@@ -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: '' } }],
+ })
+ 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': #