From 1e7c468c03e50d48e65b3ec862e59fa4b1d4fcb8 Mon Sep 17 00:00:00 2001 From: Makito Date: Sat, 29 Aug 2026 04:46:27 +0900 Subject: [PATCH] feat(provider): add voicevox family speech providers (#2383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/` Screenshot 2026-08-28 at 23 33 30 Screenshot 2026-08-28 at 23 34 14 ## Linked Issues Closes #2166 --- cspell.config.yaml | 6 + packages/i18n/src/locales/en/settings.yaml | 20 ++ .../providers/speech/aivis-speech.vue | 21 ++ .../settings/providers/speech/voicevox.vue | 21 ++ .../components/scenarios/providers/index.ts | 2 + .../providers/speech-provider-settings.vue | 64 +++-- .../voicevox-family-settings.browser.test.ts | 159 +++++++++++++ .../providers/voicevox-family-settings.vue | 161 +++++++++++++ .../composables/use-provider-validation.ts | 34 +-- .../stage-ui/src/libs/providers/attributes.ts | 2 + .../src/libs/providers/providers/index.ts | 1 + .../providers/provider-definitions.test.ts | 2 + .../providers/voicevox/define.test.ts | 196 +++++++++++++++ .../providers/providers/voicevox/define.ts | 223 ++++++++++++++++++ .../providers/voicevox/engine.test.ts | 220 +++++++++++++++++ .../providers/providers/voicevox/engine.ts | 211 +++++++++++++++++ .../providers/providers/voicevox/index.ts | 15 ++ .../src/stores/modules/speech.test.ts | 90 ++++++- .../stage-ui/src/stores/modules/speech.ts | 23 +- 19 files changed, 1435 insertions(+), 36 deletions(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/speech/aivis-speech.vue create mode 100644 packages/stage-pages/src/pages/settings/providers/speech/voicevox.vue create mode 100644 packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts create mode 100644 packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.vue create mode 100644 packages/stage-ui/src/libs/providers/providers/voicevox/define.test.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/voicevox/define.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/voicevox/engine.test.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/voicevox/engine.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/voicevox/index.ts 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': # 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 }) + }) +}) diff --git a/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.vue b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.vue new file mode 100644 index 000000000..989f4c834 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.vue @@ -0,0 +1,161 @@ + + + diff --git a/packages/stage-ui/src/composables/use-provider-validation.ts b/packages/stage-ui/src/composables/use-provider-validation.ts index d63c3f50e..dcec1cdcd 100644 --- a/packages/stage-ui/src/composables/use-provider-validation.ts +++ b/packages/stage-ui/src/composables/use-provider-validation.ts @@ -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 { + 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 diff --git a/packages/stage-ui/src/libs/providers/attributes.ts b/packages/stage-ui/src/libs/providers/attributes.ts index 12089052c..15f617836 100644 --- a/packages/stage-ui/src/libs/providers/attributes.ts +++ b/packages/stage-ui/src/libs/providers/attributes.ts @@ -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, diff --git a/packages/stage-ui/src/libs/providers/providers/index.ts b/packages/stage-ui/src/libs/providers/providers/index.ts index 21cde727b..4ac560c84 100644 --- a/packages/stage-ui/src/libs/providers/providers/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/index.ts @@ -50,6 +50,7 @@ import './azure-ai-foundry' import './official' import './speech-noop' import './unspeech' +import './voicevox' export { getDefaultStreamingModel, diff --git a/packages/stage-ui/src/libs/providers/providers/provider-definitions.test.ts b/packages/stage-ui/src/libs/providers/providers/provider-definitions.test.ts index f34e1b75d..7972a1e3c 100644 --- a/packages/stage-ui/src/libs/providers/providers/provider-definitions.test.ts +++ b/packages/stage-ui/src/libs/providers/providers/provider-definitions.test.ts @@ -48,6 +48,8 @@ describe('migrated provider definitions', () => { 'player2-speech', 'kokoro-local', 'google-gemini-audio-speech', + 'voicevox', + 'aivis-speech', ] for (const providerId of providerIds) diff --git a/packages/stage-ui/src/libs/providers/providers/voicevox/define.test.ts b/packages/stage-ui/src/libs/providers/providers/voicevox/define.test.ts new file mode 100644 index 000000000..83d4ccb2c --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/voicevox/define.test.ts @@ -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/) + }) +}) diff --git a/packages/stage-ui/src/libs/providers/providers/voicevox/define.ts b/packages/stage-ui/src/libs/providers/providers/voicevox/define.ts new file mode 100644 index 000000000..d19cbc46a --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/voicevox/define.ts @@ -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> + +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 { + const configSchema = createVoicevoxConfigSchema(options.defaultBaseUrl) + + return defineProvider({ + 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, + } +} diff --git a/packages/stage-ui/src/libs/providers/providers/voicevox/engine.test.ts b/packages/stage-ui/src/libs/providers/providers/voicevox/engine.test.ts new file mode 100644 index 000000000..91fe3cd29 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/voicevox/engine.test.ts @@ -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('')) + + 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') + }) +}) diff --git a/packages/stage-ui/src/libs/providers/providers/voicevox/engine.ts b/packages/stage-ui/src/libs/providers/providers/voicevox/engine.ts new file mode 100644 index 000000000..8d813eb0b --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/voicevox/engine.ts @@ -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 + +/** `audio_query` sends no body: its text and style id travel in the query string. */ +const POST_ENDPOINTS = new Set(['audio_query', 'synthesis']) + +interface VoicevoxEngineRequest { + baseUrl: string + body?: unknown + endpoint: VoicevoxEngineEndpoint + query?: Record +} + +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 { + 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 { + const response = await request({ baseUrl, endpoint: 'speakers' }, options) + const speakers = await decodeJson(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 { + const query = { speaker: synthesis.styleId, text: synthesis.text } + const audioQueryResponse = await request({ baseUrl, endpoint: 'audio_query', query }, options) + const audioQuery = applyVoicevoxParameters( + await decodeJson(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(response: Response, endpoint: string): Promise { + 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 { + 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 +} diff --git a/packages/stage-ui/src/libs/providers/providers/voicevox/index.ts b/packages/stage-ui/src/libs/providers/providers/voicevox/index.ts new file mode 100644 index 000000000..60badf08d --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/voicevox/index.ts @@ -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', +}) diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index 857648207..6ad0cbf9d 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -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 }) + }) +}) diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index 83459baa1..30282ed4e 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -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)