diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index f4bc0d2c4..6b74496db 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -412,6 +412,19 @@ function lookupRecommendedVoiceId(locale: string, map: Record): return undefined } +function findRecommendedVoice(voices: VoiceInfo[], recommendedMap: Record): VoiceInfo | undefined { + const seen = new Set() + for (const voiceId of Object.values(recommendedMap)) { + if (seen.has(voiceId)) + continue + seen.add(voiceId) + const voice = voices.find(v => v.id === voiceId) + if (voice) + return voice + } + return undefined +} + const AUTO_PICK_PROVIDER_IDS = new Set([OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]) // NOTICE: Only the official speech providers (HTTP + streaming) auto-configure @@ -428,12 +441,12 @@ export function setupOfficialSpeechAutoPick(ctx: { watch([ctx.availableVoices, ctx.activeSpeechProvider], ([voices, provider]) => { if (!AUTO_PICK_PROVIDER_IDS.has(provider)) return - if (ctx.activeSpeechVoiceId.value) - return const providerVoices = voices[provider] if (!providerVoices?.length) return + if (ctx.activeSpeechVoiceId.value && providerVoices.some(v => v.id === ctx.activeSpeechVoiceId.value)) + return const localeCodes = Array.from(new Set( providerVoices.flatMap(v => (v.languages || []).map(l => l.code).filter(Boolean)), @@ -449,13 +462,16 @@ export function setupOfficialSpeechAutoPick(ctx: { // voice when nothing matches): // 1) server-recommended voice for the exact locale, then the same // language prefix - // 2) first voice speaking the exact target locale - // 3) any English voice (en-US, then en-*) — broadest comprehensible + // 2) any other server-recommended voice for the same model + // 3) first voice speaking the exact target locale + // 4) any English voice (en-US, then en-*) — broadest comprehensible // fallback when the user's locale has no coverage at all - // 4) alphabetical first voice, as a last resort - const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedVoicesByProvider[provider] ?? {}) + // 5) alphabetical first voice, as a last resort + const recommendedMap = recommendedVoicesByProvider[provider] ?? {} + const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedMap) const speaksLocale = (v: VoiceInfo, code: string) => (v.languages || []).some(l => l.code === code) const match = (recommendedId && providerVoices.find(v => v.id === recommendedId)) + || findRecommendedVoice(providerVoices, recommendedMap) || providerVoices.find(v => speaksLocale(v, targetLocale)) || providerVoices.find(v => speaksLocale(v, 'en-US')) || providerVoices.find(v => (v.languages || []).some(l => l.code.toLowerCase().startsWith('en'))) diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index 07ac9e311..b17fcf6da 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -5,15 +5,20 @@ import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, pro import { useProvidersStore } from '../providers' import { toSignedPercent, useSpeechStore, voicePackForSpeechProvider } from './speech' +const i18nState = vi.hoisted(() => ({ + locale: { value: 'en-US' }, +})) + vi.mock('vue-i18n', () => ({ useI18n: () => ({ - locale: { value: 'en-US' }, + locale: i18nState.locale, t: (_key: string, fallback?: string) => fallback ?? _key, }), })) describe('speech store helpers', () => { beforeEach(() => { + i18nState.locale.value = 'en-US' setActivePinia(createPinia()) }) @@ -339,4 +344,119 @@ describe('speech store helpers', () => { vi.unstubAllGlobals() } }) + + /** + * @example + * await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'microsoft/v1') + */ + it('uses the server recommended voice when the persisted official voice is stale', async () => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }) + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString() + if (url.includes('/api/v1/audio/models')) { + return new Response(JSON.stringify({ + models: [{ id: 'microsoft/v1', name: 'microsoft/v1' }], + default: 'microsoft/v1', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + voices: [ + { + id: 'en-US-JennyNeural', + name: 'Jenny', + languages: [{ code: 'en-US', title: 'English' }], + }, + { + id: 'en-US-AvaMultilingualNeural', + name: 'Ava', + languages: [{ code: 'en-US', title: 'English' }], + }, + ], + recommended: { 'en-US': 'en-US-AvaMultilingualNeural' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch) + + const providersStore = useProvidersStore() + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechModel = 'old-model' + speechStore.activeSpeechVoiceId = 'old-model-voice' + + try { + providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( + {}, + providerOfficialSpeech.createProvider({}), + ) + + speechStore.ensureActiveSpeechModel() + await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel) + + expect(speechStore.activeSpeechModel).toBe('microsoft/v1') + expect(speechStore.activeSpeechVoiceId).toBe('en-US-AvaMultilingualNeural') + } + finally { + vi.unstubAllGlobals() + } + }) + + /** + * @example + * await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'microsoft/v1') + */ + it('uses another server recommended voice when the current locale has no recommendation', async () => { + i18nState.locale.value = 'ko-KR' + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }) + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString() + if (url.includes('/api/v1/audio/models')) { + return new Response(JSON.stringify({ + models: [{ id: 'microsoft/v1', name: 'microsoft/v1' }], + default: 'microsoft/v1', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ + voices: [ + { + id: 'ko-KR-SunHiNeural', + name: 'SunHi', + languages: [{ code: 'ko-KR', title: 'Korean' }], + }, + { + id: 'zh-CN-XiaochenNeural', + name: 'Xiaochen', + languages: [{ code: 'zh-CN', title: 'Chinese' }], + }, + ], + recommended: { 'zh-CN': 'zh-CN-XiaochenNeural' }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch) + + const providersStore = useProvidersStore() + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + + try { + providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( + {}, + providerOfficialSpeech.createProvider({}), + ) + + speechStore.ensureActiveSpeechModel() + await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel) + + expect(speechStore.activeSpeechModel).toBe('microsoft/v1') + expect(speechStore.activeSpeechVoiceId).toBe('zh-CN-XiaochenNeural') + } + finally { + vi.unstubAllGlobals() + } + }) })