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 3e49ba761c
commit a504363cd6
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<{ const props = withDefaults(defineProps<{
word: string; word: string;
pronunciation?: string | null;
opinion: string; opinion: string;
link?: unknown; link?: unknown;
escape?: boolean; escape?: boolean;
@ -42,7 +41,7 @@ const op = computed((): (Opinion & { description: string }) | null => {
</Tooltip> </Tooltip>
<nuxt-link v-if="link" :to="link" :class="`colour-${op.colour || 'default'}`"><Spelling :escape="escape" :text="word" /></nuxt-link> <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" /> <Spelling v-else :escape="escape" :markdown="markdown" :text="word" class="d-inline-block" />
<Pronunciation v-if="pronunciation" :pronunciation="pronunciation" text /> <slot></slot>
</span> </span>
</template> </template>

View File

@ -186,10 +186,16 @@ const usedOpinions = computed(() => {
:opinion="s.el.opinion" :opinion="s.el.opinion"
:escape="false" :escape="false"
:markdown="profile.markdown" :markdown="profile.markdown"
:pronunciation="s.el.pronunciation"
:link="config.locale === 'tok' && config.pronouns.enabled ? `${config.pronouns.prefix}/${s.el.value}` : null" :link="config.locale === 'tok' && config.pronouns.enabled ? `${config.pronouns.prefix}/${s.el.value}` : null"
:custom-opinions="profile.opinions" :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> </template>
</ExpandableList> </ExpandableList>
</div> </div>

View File

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

View File

@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { escapePronunciationString, unescapePronunciationString } from '#shared/helpers.ts'; import { escapePronunciationString, unescapePronunciationString } from '#shared/helpers.ts';
import { voices } from '#shared/pronunciation/voices.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts'; import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import useConfig from '~/composables/useConfig.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 config = useConfig();
const rawPronunciation = computed({ const rawPronunciation = computed({
get(): string { get(): string {
if (modelValue.value) { if (pronounciationModelValue.value) {
const phonemes = modelValue.value.substring(1, modelValue.value.length - 1); const phonemes = pronounciationModelValue.value.substring(1, pronounciationModelValue.value.length - 1);
return unescapePronunciationString(phonemes); return unescapePronunciationString(phonemes);
} else { } else {
return ''; return '';
@ -24,20 +26,33 @@ const rawPronunciation = computed({
pronunciation = null; pronunciation = null;
} }
modelValue.value = pronunciation; pronounciationModelValue.value = pronunciation;
}, },
}); });
const voices = computed((): VoiceKey[] => { watch(pronounciationModelValue, (value, oldValue) => {
if (!config.pronunciation?.enabled) { if (oldValue !== null) {
return []; 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> </script>
<template> <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> <span class="input-group-text">/</span>
<input <input
v-model="rawPronunciation" v-model="rawPronunciation"
@ -46,12 +61,28 @@ const voices = computed((): VoiceKey[] => {
maxlength="255" maxlength="255"
> >
<span class="input-group-text">/</span> <span class="input-group-text">/</span>
<PronunciationSpeaker <template v-if="pronounciationModelValue !== null">
v-for="voice in voices" <select
:key="voice" v-model="voiceModelValue"
class="btn btn-sm rounded-start-0 btn-outline-secondary" class="form-control"
:pronunciation="modelValue" :class="voiceModelValue === null ? 'text-muted' : ''"
:voice="voice" >
/> <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> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@ -844,6 +844,8 @@ profile:
names: 'Namen' names: 'Namen'
pronunciation: pronunciation:
ipa: 'Aussprache in IPA' ipa: 'Aussprache in IPA'
voice:
without: '(ohne Sprachausgabe)'
pronouns: 'Pronomen' pronouns: 'Pronomen'
pronounsInfo: > 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“) 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' names: 'Names'
pronunciation: pronunciation:
ipa: 'Pronunciation using IPA' ipa: 'Pronunciation using IPA'
voice:
without: '(without voice)'
pronouns: 'Pronouns' pronouns: 'Pronouns'
pronounsInfo: > pronounsInfo: >
You can enter a <strong>pronoun</strong> (eg. “they” or “she/her”) 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)), opinions: propv('opinions', () => JSON.parse(profile.opinions)),
names: propv('names', () => { names: propv('names', () => {
return JSON.parse(profile.names).map((name: ValueOpinion) => { 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)), 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 imagesRoute from './express/images.ts';
import mfaRoute from './express/mfa.ts'; import mfaRoute from './express/mfa.ts';
import profileRoute from './express/profile.ts'; import profileRoute from './express/profile.ts';
import pronounceRoute from './express/pronounce.ts';
import sentryRoute from './express/sentry.ts'; import sentryRoute from './express/sentry.ts';
import subscriptionRoute from './express/subscription.ts'; import subscriptionRoute from './express/subscription.ts';
import translationsRoute from './express/translations.ts'; import translationsRoute from './express/translations.ts';
@ -148,7 +147,6 @@ router.use(userRoute);
router.use(profileRoute); router.use(profileRoute);
router.use(adminRoute); router.use(adminRoute);
router.use(mfaRoute); router.use(mfaRoute);
router.use(pronounceRoute);
router.use(censusRoute); router.use(censusRoute);
router.use(imagesRoute); router.use(imagesRoute);
router.use(calendarRoute); router.use(calendarRoute);

View File

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