refactor(speech): remove selectedLanguage from speech store and related components

This commit is contained in:
RainbowBird
2026-04-25 03:38:58 +08:00
parent c6c0494998
commit 0b30ced0c2
4 changed files with 19 additions and 96 deletions
@@ -44,7 +44,6 @@ const {
speechProviderError,
ssmlEnabled,
availableVoices,
selectedLanguage,
} = storeToRefs(speechStore)
const { trackProviderClick } = useAnalytics()
@@ -467,7 +466,6 @@ function handleDeleteProvider(providerId: string) {
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
v-model:language-filter="selectedLanguage"
:show-visualizer="false"
:voices="availableVoices[activeSpeechProvider]?.filter(voice => {
// If no model is selected, show all voices
@@ -476,30 +474,14 @@ function handleDeleteProvider(providerId: string) {
}
// If a model is selected, filter by compatibility
return !voice.compatibleModels || voice.compatibleModels.includes(activeSpeechModel)
}).map(voice => {
// Promote the voice's own-language display name (e.g. 晓甄, なのみ)
// to the card title when it actually differs from the romanized
// fallback. Upstreams put this name in languages[].title keyed by
// locale code; pick the entry matching the currently-filtered
// language so multilingual voices resolve correctly.
const localized = (voice.languages || []).find(l => l.code === selectedLanguage)
const displayName = localized && localized.title && localized.title !== voice.name
? localized.title
: voice.name
return {
id: voice.id,
name: displayName,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
// Show plain locale codes in the tag row; the localized name
// that used to surface here is now the card title.
languages: (voice.languages || []).map(l => ({ name: l.code, code: l.code })),
labels: voice.gender ? { gender: voice.gender } : undefined,
}
})"
}).map(voice => ({
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))"
:searchable="true"
:filterable-by-language="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { Select } from '@proj-airi/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import Alert from '../misc/alert.vue'
@@ -8,9 +7,6 @@ import VoiceCard from './voice-card.vue'
const props = withDefaults(defineProps<Props>(), {
columns: 2,
searchable: true,
filterableByLanguage: false,
languageFilterPlaceholder: 'Filter by language',
languageFilterAllLabel: 'All languages',
searchPlaceholder: 'Search voices...',
searchNoResultsTitle: 'No voices found',
searchNoResultsDescription: 'Try a different search term',
@@ -26,11 +22,6 @@ const props = withDefaults(defineProps<Props>(), {
listClass: '',
})
// NOTICE: reka-ui's SelectItem rejects empty-string values (they represent
// "no value" in Radix primitives). Use a sentinel for the "All languages"
// option and map back to '' externally.
const ALL_LANGUAGES_SENTINEL = '__all__'
interface VoiceLanguage {
name: string
code: string
@@ -58,9 +49,6 @@ interface Props {
voices: Voice[]
columns?: number
searchable?: boolean
filterableByLanguage?: boolean
languageFilterPlaceholder?: string
languageFilterAllLabel?: string
searchPlaceholder?: string
searchNoResultsTitle?: string
searchNoResultsDescription?: string
@@ -96,47 +84,14 @@ function initAudioContext() {
const searchQuery = defineModel<string>('search-query', { required: false, default: '' })
const voiceId = defineModel<string>('voice-id', { required: false, default: '' })
const languageFilter = defineModel<string>('language-filter', { required: false, default: '' })
// Unique language codes across voices, stable-sorted. Used to populate the
// optional language filter selector.
const availableLanguages = computed(() => {
const codes = new Set<string>()
for (const v of props.voices) {
for (const l of v.languages || []) {
if (l.code)
codes.add(l.code)
}
}
return Array.from(codes).sort()
})
const languageOptions = computed(() => [
{ label: props.languageFilterAllLabel, value: ALL_LANGUAGES_SENTINEL },
...availableLanguages.value.map(code => ({ label: code, value: code })),
])
const languageFilterModel = computed<string>({
get: () => languageFilter.value || ALL_LANGUAGES_SENTINEL,
set: (v) => {
languageFilter.value = v === ALL_LANGUAGES_SENTINEL ? '' : v
},
})
const languageFilteredVoices = computed(() => {
if (!props.filterableByLanguage || !languageFilter.value)
return props.voices
return props.voices.filter(v => (v.languages || []).some(l => l.code === languageFilter.value))
})
// Filter voices based on search query
const filteredVoices = computed(() => {
const base = languageFilteredVoices.value
if (!searchQuery.value)
return base
return props.voices
const query = searchQuery.value.toLowerCase()
return base.filter((voice) => {
return props.voices.filter((voice) => {
// Search in name and description
const nameMatch = voice.name.toLowerCase().includes(query)
const descMatch = voice.description && voice.description.toLowerCase().includes(query)
@@ -361,15 +316,6 @@ const customVoiceName = ref('')
<template>
<div class="voice-preview-player">
<!-- Language filter -->
<div v-if="filterableByLanguage && availableLanguages.length > 1" class="mb-2">
<Select
v-model="languageFilterModel"
:options="languageOptions"
:placeholder="languageFilterPlaceholder"
/>
</div>
<!-- Search bar -->
<div v-if="searchable" class="relative" inline-flex="~" w-full items-center>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
@@ -192,12 +192,13 @@ function lookupRecommendedVoiceId(locale: string, map: Record<string, string>):
}
// NOTICE: Only the official speech provider auto-configures a default voice
// after login. Third-party providers leave voice selection to the user.
// after login. Third-party providers leave voice selection to the user. The
// target locale is derived from the UI locale on each run — we don't persist
// it, since that was the root of the cross-provider filter drift bug.
export function setupOfficialSpeechAutoPick(ctx: {
activeSpeechProvider: Ref<string>
activeSpeechVoiceId: Ref<string>
availableVoices: Ref<Record<string, VoiceInfo[]>>
selectedLanguage: Ref<string>
uiLocale: WatchSource<string> | Ref<string>
}) {
watch([ctx.availableVoices, ctx.activeSpeechProvider], ([voices, provider]) => {
@@ -214,26 +215,24 @@ export function setupOfficialSpeechAutoPick(ctx: {
providerVoices.flatMap(v => (v.languages || []).map(l => l.code).filter(Boolean)),
)).sort()
if (!ctx.selectedLanguage.value || !localeCodes.includes(ctx.selectedLanguage.value)) {
const uiLocaleValue = typeof ctx.uiLocale === 'function'
? (ctx.uiLocale as () => string)()
: (ctx.uiLocale as Ref<string>).value
ctx.selectedLanguage.value = pickLocaleForUi(uiLocaleValue, localeCodes)
}
const uiLocaleValue = typeof ctx.uiLocale === 'function'
? (ctx.uiLocale as () => string)()
: (ctx.uiLocale as Ref<string>).value
const targetLocale = pickLocaleForUi(uiLocaleValue, localeCodes)
// Pick a default voice with a layered fallback so auto-pick never dumps
// the user into an unrelated voice (e.g. the alphabetically-first af-ZA
// voice when nothing matches):
// 1) server-recommended voice for the exact locale, then the same
// language prefix
// 2) first voice speaking the exact selected locale
// 2) first voice speaking the exact target locale
// 3) 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(ctx.selectedLanguage.value, recommendedVoicesByLocale)
const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedVoicesByLocale)
const speaksLocale = (v: VoiceInfo, code: string) => (v.languages || []).some(l => l.code === code)
const match = (recommendedId && providerVoices.find(v => v.id === recommendedId))
|| providerVoices.find(v => speaksLocale(v, ctx.selectedLanguage.value))
|| 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')))
|| providerVoices[0]
@@ -39,7 +39,6 @@ export const useSpeechStore = defineStore('speech', () => {
const isLoadingSpeechProviderVoices = refManualReset<boolean>(false)
const speechProviderError = refManualReset<string | null>(null)
const availableVoices = refManualReset<Record<string, VoiceInfo[]>>(() => ({}))
const selectedLanguage = useLocalStorageManualReset<string>('settings/speech/language', 'en-US')
const modelSearchQuery = refManualReset<string>('')
// Computed properties
@@ -171,7 +170,6 @@ export const useSpeechStore = defineStore('speech', () => {
activeSpeechProvider,
activeSpeechVoiceId,
availableVoices,
selectedLanguage,
uiLocale: locale,
})
@@ -302,7 +300,6 @@ export const useSpeechStore = defineStore('speech', () => {
pitch.reset()
rate.reset()
ssmlEnabled.reset()
selectedLanguage.reset()
modelSearchQuery.reset()
availableVoices.reset()
speechProviderError.reset()
@@ -319,7 +316,6 @@ export const useSpeechStore = defineStore('speech', () => {
pitch,
rate,
ssmlEnabled,
selectedLanguage,
isLoadingSpeechProviderVoices,
speechProviderError,
availableVoices,