refactor(speech): remove selectedLanguage from speech store and related components
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user