feat(stage-ui): add function to find recommended voice from available voices
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user