feat(profile): configurable voice for pronunciation (defaulting to the first voice of the locale or gb)

This commit is contained in:
Valentyne Stigloher 2025-09-15 09:47:14 +02:00
parent 85c6e38d1b
commit 60ce203a11
13 changed files with 101 additions and 39 deletions

View File

@ -4,7 +4,6 @@ import type { Opinion } from '#shared/opinions.ts';
const props = withDefaults(defineProps<{
word: string;
pronunciation?: string | null;
opinion: string;
link?: unknown;
escape?: boolean;
@ -42,7 +41,7 @@ const op = computed((): (Opinion & { description: string }) | null => {
</Tooltip>
<nuxt-link v-if="link" :to="link" :class="`colour-${op.colour || 'default'}`"><Spelling :escape="escape" :text="word" /></nuxt-link>
<Spelling v-else :escape="escape" :markdown="markdown" :text="word" class="d-inline-block" />
<Pronunciation v-if="pronunciation" :pronunciation="pronunciation" text />
<slot></slot>
</span>
</template>

View File

@ -186,10 +186,16 @@ const usedOpinions = computed(() => {
:opinion="s.el.opinion"
:escape="false"
:markdown="profile.markdown"
:pronunciation="s.el.pronunciation"
:link="config.locale === 'tok' && config.pronouns.enabled ? `${config.pronouns.prefix}/${s.el.value}` : null"
:custom-opinions="profile.opinions"
/>
>
<Pronunciation
v-if="s.el.pronunciation"
:pronunciation="s.el.pronunciation"
text
:voices="s.el.voice !== null ? [s.el.voice] : []"
/>
</Opinion>
</template>
</ExpandableList>
</div>

View File

@ -2,18 +2,19 @@
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import useConfig from '~/composables/useConfig.ts';
defineProps<{
withDefaults(defineProps<{
pronunciation: string;
text?: boolean;
}>();
voices?: VoiceKey[];
}>(), {
voices: () => {
const config = useConfig();
const config = useConfig();
const voices = computed((): VoiceKey[] => {
if (!config.pronunciation?.enabled) {
return [];
}
return config.pronunciation.voices;
if (!config.pronunciation?.enabled) {
return [];
}
return config.pronunciation.voices;
},
});
</script>

View File

@ -1,16 +1,18 @@
<script setup lang="ts">
import { escapePronunciationString, unescapePronunciationString } from '#shared/helpers.ts';
import { voices } from '#shared/pronunciation/voices.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import useConfig from '~/composables/useConfig.ts';
const modelValue = defineModel<string | null>({ required: true });
const pronounciationModelValue = defineModel<string | null>('pronounciation', { required: true });
const voiceModelValue = defineModel<VoiceKey | null>('voice', { required: true });
const config = useConfig();
const rawPronunciation = computed({
get(): string {
if (modelValue.value) {
const phonemes = modelValue.value.substring(1, modelValue.value.length - 1);
if (pronounciationModelValue.value) {
const phonemes = pronounciationModelValue.value.substring(1, pronounciationModelValue.value.length - 1);
return unescapePronunciationString(phonemes);
} else {
return '';
@ -24,20 +26,33 @@ const rawPronunciation = computed({
pronunciation = null;
}
modelValue.value = pronunciation;
pronounciationModelValue.value = pronunciation;
},
});
const voices = computed((): VoiceKey[] => {
if (!config.pronunciation?.enabled) {
return [];
watch(pronounciationModelValue, (value, oldValue) => {
if (oldValue !== null) {
return;
}
return config.pronunciation.voices;
// reset voice to default voice
if (!config.pronunciation?.enabled) {
voiceModelValue.value = 'gb';
} else {
voiceModelValue.value = config.pronunciation.voices[0];
}
});
const sortedVoices = computed(() => {
const voicesOfLocale = config.pronunciation?.enabled ? config.pronunciation.voices : [];
return (Object.keys(voices) as VoiceKey[]).toSorted((a, b) => {
return (voicesOfLocale.includes(b) ? 1 : 0) - (voicesOfLocale.includes(a) ? 1 : 0);
});
});
</script>
<template>
<div class="input-group input-group-sm w-auto">
<div class="input-group input-group-sm w-50">
<span class="input-group-text">/</span>
<input
v-model="rawPronunciation"
@ -46,12 +61,28 @@ const voices = computed((): VoiceKey[] => {
maxlength="255"
>
<span class="input-group-text">/</span>
<PronunciationSpeaker
v-for="voice in voices"
:key="voice"
class="btn btn-sm rounded-start-0 btn-outline-secondary"
:pronunciation="modelValue"
:voice="voice"
/>
<template v-if="pronounciationModelValue !== null">
<select
v-model="voiceModelValue"
class="form-control"
:class="voiceModelValue === null ? 'text-muted' : ''"
>
<option :value="null">
<T>profile.pronunciation.voice.without</T>
</option>
<option v-for="voice of sortedVoices" :key="voice" :value="voice">
{{ voice.toUpperCase() }}
</option>
</select>
<PronunciationSpeaker
v-if="voiceModelValue !== null"
class="btn btn-sm rounded-start-0 btn-outline-secondary"
:pronunciation="pronounciationModelValue"
:voice="voiceModelValue"
/>
<button v-else class="btn btn-sm rounded-start-0 btn-outline-secondary" type="button" disabled>
<Icon v="volume-slash" />
</button>
</template>
</div>
</template>

View File

@ -465,13 +465,16 @@ const propagateChanged = (field: string, checked: boolean): void => {
</p>
<OpinionListInput
v-model="formData.names"
:prototype="{ value: '', opinion: 'meh', pronunciation: null }"
:prototype="{ value: '', opinion: 'meh', pronunciation: null, voice: null }"
:custom-opinions="formData.opinions"
:maxitems="128"
:maxlength="config.profile.longNames ? 255 : 32"
>
<template #additional="s">
<PronunciationInput v-model="s.val.pronunciation" />
<PronunciationInput
v-model:pronounciation="s.val.pronunciation"
v-model:voice="s.val.voice"
/>
</template>
</OpinionListInput>
<InlineMarkdownInstructions v-model="formData.markdown" />

View File

@ -728,6 +728,8 @@ profile:
names: 'Names'
pronunciation:
ipa: 'Pronunciation using IPA'
voice:
without: '(without voice)'
pronouns: 'Pronouns'
pronounsInfo: >
You can enter a <strong>pronoun</strong> (eg. “they” or “she/her”)

View File

@ -488,11 +488,7 @@ pronouns:
pronunciation:
enabled: true
voices:
DE:
language: 'de-DE'
voice: 'Vicki'
engine: 'standard'
voices: ['de']
sources:
enabled: true

View File

@ -844,6 +844,8 @@ profile:
names: 'Namen'
pronunciation:
ipa: 'Aussprache in IPA'
voice:
without: '(ohne Sprachausgabe)'
pronouns: 'Pronomen'
pronounsInfo: >
Du kannst entweder ein <strong>Pronomen</strong> (z.B. „sier“ oder „sie/ihr“) oder einen <strong>Link</strong> (z.B. „https://pronomen.net/dey“)

View File

@ -944,6 +944,8 @@ profile:
names: 'Names'
pronunciation:
ipa: 'Pronunciation using IPA'
voice:
without: '(without voice)'
pronouns: 'Pronouns'
pronounsInfo: >
You can enter a <strong>pronoun</strong> (eg. “they” or “she/her”)

View File

@ -0,0 +1,20 @@
-- Up
update profiles
set names = (select json_group_array(case
when json_type(names.value, '$.pronunciation') is 'text'
then json_set(names.value, '$.voice',
case
when locale in ('en', 'eo', 'fo', 'hu', 'tok', 'ua', 'vi')
then 'gb'
when locale = 'et' then 'fi'
when locale = 'lad' then 'es'
when locale = 'nn' then 'nb'
when locale = 'sv' then 'se'
when locale = 'zh' then 'cn'
else locale end)
else json(names.value) end)
from json_each(profiles.names) as names)
where 1 = 1;
-- Down

View File

@ -348,7 +348,7 @@ const fetchProfiles = async (
opinions: propv('opinions', () => JSON.parse(profile.opinions)),
names: propv('names', () => {
return JSON.parse(profile.names).map((name: ValueOpinion) => {
return { pronunciation: null, ...name };
return { pronunciation: null, voice: null, ...name };
});
}),
pronouns: propv('pronouns', () => JSON.parse(profile.pronouns)),

View File

@ -19,7 +19,6 @@ import grantOverridesRoute from './express/grantOverrides.ts';
import imagesRoute from './express/images.ts';
import mfaRoute from './express/mfa.ts';
import profileRoute from './express/profile.ts';
import pronounceRoute from './express/pronounce.ts';
import sentryRoute from './express/sentry.ts';
import subscriptionRoute from './express/subscription.ts';
import translationsRoute from './express/translations.ts';
@ -148,7 +147,6 @@ router.use(userRoute);
router.use(profileRoute);
router.use(adminRoute);
router.use(mfaRoute);
router.use(pronounceRoute);
router.use(censusRoute);
router.use(imagesRoute);
router.use(calendarRoute);

View File

@ -2,6 +2,7 @@ import type { CustomEvent } from './calendar/helpers.ts';
import type { Opinion } from './opinions.ts';
import type { User } from './user.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import type { LocaleCode } from '~~/locale/locales.ts';
export interface UserWithProfiles {
@ -63,6 +64,7 @@ export interface ValueOpinion {
export interface NameOpinion extends ValueOpinion {
pronunciation: string;
voice: VoiceKey | null;
}
export interface Timezone {