feat(stage-ui): add function to find recommended voice from available voices

This commit is contained in:
RainbowBird
2026-06-28 17:25:01 +08:00
parent 8036501fb0
commit cc6fe4e6e3
2 changed files with 143 additions and 7 deletions
@@ -412,6 +412,19 @@ function lookupRecommendedVoiceId(locale: string, map: Record<string, string>):
return undefined
}
function findRecommendedVoice(voices: VoiceInfo[], recommendedMap: Record<string, string>): VoiceInfo | undefined {
const seen = new Set<string>()
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')))
@@ -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()
}
})
})