Merge branch 'configurable-voice-p' into 'main'

configurable voices

See merge request PronounsPage/PronounsPage!652
This commit is contained in:
Valentyne Stigloher 2025-09-17 09:21:21 +00:00
commit d5656ef739
45 changed files with 461 additions and 360 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

@ -1,50 +1,35 @@
<script setup lang="ts">
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import useConfig from '~/composables/useConfig.ts';
withDefaults(defineProps<{
pronunciation: string;
text?: boolean;
voices?: VoiceKey[];
}>(), {
voices: () => {
const config = useConfig();
if (!config.pronunciation?.enabled) {
return [];
}
return config.pronunciation.voices;
},
});
</script>
<template> <template>
<span class="text-nowrap"> <span class="text-nowrap">
<span v-if="text" class="text-pronunciation"> <span v-if="text" class="text-secondary-emphasis fw-normal">
{{ pronunciation }} {{ pronunciation }}
</span> </span>
<PronunciationSpeaker <PronunciationSpeaker
v-for="voice in voices" v-for="voice in voices"
:key="voice" :key="voice"
:pronunciation="pronunciation" :pronunciation
:voice="voice" :voice
:show-voice-label="voices.length > 1"
class="btn btn-sm btn-link px-1 py-0" class="btn btn-sm btn-link px-1 py-0"
/> />
</span> </span>
</template> </template>
<script lang="ts">
import { defineComponent } from 'vue';
import useConfig from '../composables/useConfig.ts';
export default defineComponent({
props: {
pronunciation: { required: true, type: String },
text: { default: false, type: Boolean },
},
setup() {
return {
config: useConfig(),
};
},
computed: {
voices(): string[] {
if (this.config.pronunciation?.enabled) {
return Object.keys(this.config.pronunciation.voices);
} else {
return [];
}
},
},
});
</script>
<style lang="scss" scoped>
@import "~/assets/variables";
.text-pronunciation {
font-weight: normal;
color: var(--#{$prefix}secondary-color);
}
</style>

View File

@ -1,46 +1,18 @@
<template> <script setup lang="ts">
<div class="input-group input-group-sm w-auto">
<span class="input-group-text">/</span>
<input
v-model="rawPronunciation"
class="form-control mw-input"
:placeholder="$t('profile.pronunciation.ipa')"
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"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import useConfig from '../composables/useConfig.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 useConfig from '~/composables/useConfig.ts';
export default defineComponent({ const pronounciationModelValue = defineModel<string | null>('pronounciation', { required: true });
props: { const voiceModelValue = defineModel<VoiceKey | null>('voice', { required: true });
modelValue: { default: null, type: String as PropType<string | null> },
}, const config = useConfig();
emits: ['update:modelValue'],
setup() { const rawPronunciation = computed({
return {
config: useConfig(),
};
},
computed: {
rawPronunciation: {
get(): string { get(): string {
if (this.modelValue) { if (pronounciationModelValue.value) {
const phonemes = this.modelValue.substring(1, this.modelValue.length - 1); const phonemes = pronounciationModelValue.value.substring(1, pronounciationModelValue.value.length - 1);
return unescapePronunciationString(phonemes); return unescapePronunciationString(phonemes);
} else { } else {
return ''; return '';
@ -54,16 +26,63 @@ export default defineComponent({
pronunciation = null; pronunciation = null;
} }
this.$emit('update:modelValue', pronunciation); pronounciationModelValue.value = pronunciation;
},
},
voices(): string[] {
if (this.config.pronunciation?.enabled) {
return Object.keys(this.config.pronunciation.voices);
} else {
return [];
}
},
}, },
}); });
watch(pronounciationModelValue, (value, oldValue) => {
if (oldValue !== null) {
return;
}
// 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 entriesWithKeys(voices).toSorted((a, b) => {
return (voicesOfLocale.includes(b.key as VoiceKey) ? 1 : 0) - (voicesOfLocale.includes(a.key as VoiceKey) ? 1 : 0);
});
});
</script> </script>
<template>
<div class="input-group input-group-sm w-50">
<span class="input-group-text">/</span>
<input
v-model="rawPronunciation"
class="form-control mw-input"
:placeholder="$t('profile.pronunciation.ipa')"
maxlength="255"
>
<span class="input-group-text">/</span>
<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.key" :value="voice.key">
{{ voice.key.toUpperCase() }} {{ voice.name }}
</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

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
pronunciation?: string | null; pronunciation?: string | null;
voice: string; voice: VoiceKey;
button?: boolean; showVoiceLabel?: boolean;
}>(), { }>(), {
pronunciation: null, pronunciation: null,
}); });
@ -56,18 +58,21 @@ const icon = computed((): string => {
}); });
const config = useConfig(); const config = useConfig();
const name = computed((): string | null => { const voiceLabel = computed((): string | null => {
if (!props.showVoiceLabel) {
return null;
}
if (!config.pronunciation?.enabled) { if (!config.pronunciation?.enabled) {
return props.voice; return props.voice.toUpperCase();
} }
const voices = Object.keys(config.pronunciation.voices); const voices = config.pronunciation.voices;
if (voices.length === 1 && props.voice === voices[0]) { if (voices.length === 1 && props.voice === voices[0]) {
// dont show voice name if it is considered the main voice for this locale // dont show voice name if it is considered the main voice for this locale
return null; return null;
} }
return props.voice; return props.voice.toUpperCase();
}); });
</script> </script>
@ -81,6 +86,6 @@ const name = computed((): string | null => {
target="_blank" target="_blank"
@click.prevent="pronounce" @click.prevent="pronounce"
> >
<Icon :v="icon" /><sub v-if="name">{{ name }}</sub> <Icon :v="icon" /><sub v-if="voiceLabel">{{ voiceLabel }}</sub>
</Tooltip> </Tooltip>
</template> </template>

View File

@ -460,18 +460,24 @@ const propagateChanged = (field: string, checked: boolean): void => {
<T>profile.names</T> <T>profile.names</T>
</template> </template>
<template #names> <template #names>
<p v-if="$te('profile.namesInfo')" class="small text-muted"> <div class="alert alert-info">
<p class="small mb-0">
<Icon v="info-circle" />
<T>profile.namesInfo</T> <T>profile.namesInfo</T>
</p> </p>
</div>
<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

@ -99,11 +99,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['gb']
GB:
language: 'en-GB'
voice: 'Emma'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -726,8 +726,17 @@ user:
profile: profile:
description: 'Description' description: 'Description'
names: 'Names' names: 'Names'
namesInfo: >
You can enter your <strong>names</strong> or <strong>initials</strong> with the spelling
and optionally with their pronunciation.
Keep in mind that you need to specify pronunciation in IPA (International Phonetic Alphabet)
so that it is in an universally recognized format.
If the used voice synthesizers does not produce the correct pronunciation,
you can try switching to a different one or disabling the voice entirely if it is too far off.
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

@ -51,15 +51,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['ar', 'ae']
AR:
language: 'arb'
voice: 'Zeina'
engine: 'standard'
AE:
language: 'ar-AE'
voice: 'Hala'
engine: 'neural'
sources: sources:
enabled: false enabled: false

View File

@ -1,7 +1,6 @@
import type { Engine, LanguageCode, VoiceId } from '@aws-sdk/client-polly';
import type { Category } from '#shared/classes.ts'; import type { Category } from '#shared/classes.ts';
import type { GrammarTablesDefinition } from '#shared/language/grammarTables.ts'; import type { GrammarTablesDefinition } from '#shared/language/grammarTables.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
import type { LocaleCode } from '~~/locale/locales.ts'; import type { LocaleCode } from '~~/locale/locales.ts';
export type Toggable<T> = ({ enabled: true } & T) | { enabled: false } & Partial<T>; export type Toggable<T> = ({ enabled: true } & T) | { enabled: false } & Partial<T>;
@ -404,45 +403,7 @@ interface PronunciationConfig {
* whether to treat letter graphemes as phonemes. useful when the configured locale has no matching voice * whether to treat letter graphemes as phonemes. useful when the configured locale has no matching voice
*/ */
ipa?: boolean; ipa?: boolean;
voices: Record<string, PronunciationVoiceConfig>; voices: VoiceKey[];
}
export type PronunciationVoiceConfig = AwsPollyPronunciationVoiceConfig | NarakeetPronunciationVoiceConfig;
/**
* @see https://docs.aws.amazon.com/polly/latest/dg/voicelist.html
*/
export interface AwsPollyPronunciationVoiceConfig {
/**
* text-to-speech provider (aws_polly is the default, if not specified)
*/
provider?: 'aws_polly';
/**
* language code
*/
language: LanguageCode;
/**
* voice name
*/
voice: VoiceId;
/**
* voice engine (default: 'standard')
*/
engine?: Engine;
}
export interface NarakeetPronunciationVoiceConfig {
/**
* text-to-speech provider
*/
provider: 'narakeet';
/**
* language code
*/
language: string;
/**
* voice name
*/
voice: string;
} }
interface SourcesConfig { interface SourcesConfig {

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

@ -842,12 +842,21 @@ user:
profile: profile:
description: 'Beschreibung' description: 'Beschreibung'
names: 'Namen' names: 'Namen'
namesInfo: >
Du kannst deine <strong>Namen</strong> oder <strong>Initialen</strong> mit ihrer Schreibung
und optional ihrer Aussprache eingeben.
Beachte bitte, dass die Aussprache in IPA (Internationales Phonetisches Alphabet) eingegeben werden muss,
damit diese in einem universellen Format vorliegt.
Wenn die Sprachausgabe nicht die korrekte Aussprache erzeugt,
kannst du eine andere Sprachausgabe versuchen oder die Sprachausgabe komplett abschalten.
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“)
oder <strong>vier benutzerdefinierten Formen</strong> (z.B. „xier/xies/xiem/xien“). oder <strong>vier benutzerdefinierten Formen</strong> (z.B. „xier/xies/xiem/xien“) eingeben.
Du kannst auch den {/pronomen#generator=<strong>Generator</strong>} verwenden, um die Sätze mit benutzerdefinierten Formen auszufüllen. Du kannst auch den {/pronomen#generator=<strong>Generator</strong>} verwenden, um die Sätze mit benutzerdefinierten Formen auszufüllen.
pronounsNotFound: 'Nicht erkennbares Format. Bitte beachte die oben genannte Anweisung.' pronounsNotFound: 'Nicht erkennbares Format. Bitte beachte die oben genannte Anweisung.'
words: 'Wörter' words: 'Wörter'

View File

@ -149,11 +149,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['gb']
GB:
language: 'en-GB'
voice: 'Emma'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -942,8 +942,17 @@ user:
profile: profile:
description: 'Description' description: 'Description'
names: 'Names' names: 'Names'
namesInfo: >
You can enter your <strong>names</strong> or <strong>initials</strong> with the spelling
and optionally with their pronunciation.
Keep in mind that you need to specify pronunciation in IPA (International Phonetic Alphabet)
so that it is in an universally recognized format.
If the used voice synthesizers does not produce the correct pronunciation,
you can try switching to a different one or disabling the voice entirely if it is too far off.
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

@ -97,7 +97,6 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
voices: {}
sources: sources:
enabled: true enabled: true

View File

@ -128,15 +128,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['es', 'mx']
ES:
language: 'es-ES'
voice: 'Lucia'
engine: 'standard'
MX:
language: 'es-MX'
voice: 'Mia'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -37,11 +37,7 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
voices: voices: ['fi']
FI:
language: 'fi-FI'
voice: 'Suvi'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -133,11 +133,6 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
voices:
GB:
language: 'en-GB'
voice: 'Emma'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -65,15 +65,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['fr', 'ca']
FR:
language: 'fr-FR'
voice: 'Lea'
engine: 'standard'
CA:
language: 'fr-CA'
voice: 'Gabrielle'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -79,8 +79,7 @@ pronouns:
shortMorphemes: 3 shortMorphemes: 3
pronunciation: pronunciation:
enabled: true enabled: false
voices: {}
sources: sources:
enabled: true enabled: true

View File

@ -98,19 +98,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['bos', 'hrv', 'srp']
BOS:
language: 'bos'
voice: 'suada'
provider: 'narakeet'
HRV:
language: 'hrv'
voice: 'jasna'
provider: 'narakeet'
SRP:
language: 'srp'
voice: 'milica-latin'
provider: 'narakeet'
sources: sources:
enabled: true enabled: true

View File

@ -44,12 +44,7 @@ pronouns:
others: 'Other pronouns' others: 'Other pronouns'
pronunciation: pronunciation:
enabled: true enabled: false
voices:
GB:
language: 'en-GB'
voice: 'Emma'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -324,11 +324,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['it']
IT:
language: 'it-IT'
voice: 'Bianca'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -45,11 +45,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['ja']
JA:
language: 'ja-JP'
voice: 'Mizuki'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -40,11 +40,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['ko']
KO:
language: 'ko-KR'
voice: 'Seoyeon'
engine: 'neural'
sources: sources:
enabled: true enabled: true

View File

@ -120,11 +120,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['no']
NO:
language: 'nb-NO'
voice: 'Liv'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -71,11 +71,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['nl']
GB:
language: 'nl-NL'
voice: 'Ruben'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -121,11 +121,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['no']
NO:
language: 'nb-NO'
voice: 'Liv'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -322,11 +322,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['pl']
PL:
language: 'pl-PL'
voice: 'Ewa'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -81,15 +81,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['pt', 'br']
PT:
language: 'pt-PT'
voice: 'Cristiano'
engine: 'standard'
BR:
language: 'pt-BR'
voice: 'Vitoria'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -113,11 +113,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['ro']
RO:
language: 'ro-RO'
voice: 'Carmen'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -162,11 +162,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['ru']
GB:
language: 'ru-RU'
voice: 'Tatyana'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -50,11 +50,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['se']
SE:
language: 'sv-SE'
voice: 'Astrid'
engine: 'standard'
sources: sources:
enabled: true enabled: true

View File

@ -29,11 +29,7 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
ipa: true ipa: true
voices: voices: ['us']
TOK:
language: 'es-US'
voice: 'Lupe'
engine: 'standard'
sources: sources:
enabled: false enabled: false

View File

@ -11,11 +11,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['tr']
TR:
language: 'tr-TR'
voice: 'Filiz'
engine: 'standard'
sources: sources:
enabled: false enabled: false

View File

@ -83,7 +83,6 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
voices: {}
sources: sources:
enabled: true enabled: true

View File

@ -35,7 +35,6 @@ pronouns:
pronunciation: pronunciation:
enabled: false enabled: false
voices: {}
sources: sources:
enabled: true enabled: true

View File

@ -35,11 +35,7 @@ pronouns:
pronunciation: pronunciation:
enabled: true enabled: true
voices: voices: ['cn']
CN:
language: 'cmn-CN'
voice: 'Zhiyu'
engine: 'standard'
sources: sources:
enabled: true enabled: true

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

@ -2,31 +2,21 @@ import { Polly } from '@aws-sdk/client-polly';
import { NoSuchKey } from '@aws-sdk/client-s3'; import { NoSuchKey } from '@aws-sdk/client-s3';
import type { S3 } from '@aws-sdk/client-s3'; import type { S3 } from '@aws-sdk/client-s3';
import type { NodeJsClient } from '@smithy/types'; import type { NodeJsClient } from '@smithy/types';
import { Router } from 'express';
import { getH3Event } from 'h3-express';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import sha1 from 'sha1';
import { s3, awsConfig, s3BucketParams } from '../cloudServices.ts';
import { import {
convertPronunciationStringToSsml, convertPronunciationStringToSsml,
convertPronunciationStringToNarakeetFormat, convertPronunciationStringToNarakeetFormat,
handleErrorAsync, sha256,
} from '#shared/helpers.ts'; } from '#shared/helpers.ts';
import type { import { voices } from '#shared/pronunciation/voices.ts';
PronunciationVoiceConfig, import type { VoiceKey, AwsPollyVoice, NarakeetVoice, Voice } from '#shared/pronunciation/voices.ts';
AwsPollyPronunciationVoiceConfig, import { s3, awsConfig, s3BucketParams } from '~~/server/cloudServices.ts';
NarakeetPronunciationVoiceConfig,
} from '~~/locale/config.ts';
import { getLocale, loadConfig, localesDict } from '~~/server/data.ts';
const router = Router();
type ProviderKey = 'aws_polly' | 'narakeet'; type ProviderKey = 'aws_polly' | 'narakeet';
interface Provider { interface Provider {
tokenised(text: string): string; tokenised(text: string): string;
generate(textTokenised: string, voice: PronunciationVoiceConfig): Promise<[Uint8Array, string]>; generate(textTokenised: string, voice: Voice): Promise<[Uint8Array, string]>;
} }
const providers: Record<ProviderKey, Provider> = { const providers: Record<ProviderKey, Provider> = {
@ -34,7 +24,7 @@ const providers: Record<ProviderKey, Provider> = {
tokenised(text: string): string { tokenised(text: string): string {
return convertPronunciationStringToSsml(text); return convertPronunciationStringToSsml(text);
}, },
async generate(textTokenised: string, voice: AwsPollyPronunciationVoiceConfig): Promise<[Uint8Array, string]> { async generate(textTokenised: string, voice: AwsPollyVoice): Promise<[Uint8Array, string]> {
const polly = new Polly(awsConfig) as NodeJsClient<Polly>; const polly = new Polly(awsConfig) as NodeJsClient<Polly>;
const pollyResponse = await polly.synthesizeSpeech({ const pollyResponse = await polly.synthesizeSpeech({
@ -56,7 +46,7 @@ const providers: Record<ProviderKey, Provider> = {
tokenised(text: string): string { tokenised(text: string): string {
return convertPronunciationStringToNarakeetFormat(text); return convertPronunciationStringToNarakeetFormat(text);
}, },
async generate(textTokenised: string, voice: NarakeetPronunciationVoiceConfig): Promise<[Uint8Array, string]> { async generate(textTokenised: string, voice: NarakeetVoice): Promise<[Uint8Array, string]> {
const url = `https://api.narakeet.com/text-to-speech/mp3?voice=${voice.voice}&language=${voice.language}`; const url = `https://api.narakeet.com/text-to-speech/mp3?voice=${voice.voice}&language=${voice.language}`;
const response = await fetch(url, { const response = await fetch(url, {
@ -81,29 +71,40 @@ const providers: Record<ProviderKey, Provider> = {
}, },
}; };
router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res) => { export default defineEventHandler(async (event) => {
const text = Base64.decode(req.params.pronunciation); const text = Base64.decode(getRouterParam(event, 'pronunciation')!);
if (!text || text.length > 256) { if (!text || text.length > 256) {
return res.status(404).json({ error: 'Not found' }); throw createError({
status: 404,
statusMessage: 'Not Found',
});
} }
const locale = getLocale(getH3Event(req)); const voiceKey = getRouterParam(event, 'voice');
const config = await loadConfig(locale); if (voiceKey === undefined || !(voiceKey in voices)) {
throw createError({
const voice: PronunciationVoiceConfig | undefined = config.pronunciation?.voices?.[req.params.voice]; status: 404,
if (!voice) { statusMessage: 'Not Found',
return res.status(404).json({ error: 'Not found' }); });
} }
const voice = voices[voiceKey as VoiceKey];
const provider = providers[(voice.provider || 'aws_polly') as ProviderKey]; const provider = providers[(voice.provider || 'aws_polly') as ProviderKey];
const tokenised = provider.tokenised(text); const tokenised = provider.tokenised(text);
const key = `pronunciation/${config.locale}-${req.params.voice}/${sha1(tokenised)}.mp3`; const key = `pronunciation/${voiceKey}/${await sha256(tokenised)}.mp3`;
// bypass cache in development
if (import.meta.dev) {
const [buffer, contentType] = await provider.generate(tokenised, voice);
setResponseHeader(event, 'content-type', contentType);
return buffer;
}
try { try {
const s3Response = await (s3 as NodeJsClient<S3>).getObject({ Key: key, ...s3BucketParams }); const s3Response = await (s3 as NodeJsClient<S3>).getObject({ Key: key, ...s3BucketParams });
res.set('content-type', s3Response.ContentType); setResponseHeader(event, 'content-type', s3Response.ContentType);
return s3Response.Body!.pipe(res); return s3Response.Body;
} catch (error) { } catch (error) {
if (!(error instanceof NoSuchKey)) { if (!(error instanceof NoSuchKey)) {
throw error; throw error;
@ -111,7 +112,6 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
const [buffer, contentType] = await provider.generate(tokenised, voice); const [buffer, contentType] = await provider.generate(tokenised, voice);
if (localesDict[locale]?.published) {
// don't populate the cache for locales still under construction // don't populate the cache for locales still under construction
await s3.putObject({ await s3.putObject({
Key: key, Key: key,
@ -119,12 +119,8 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
ContentType: contentType, ContentType: contentType,
...s3BucketParams, ...s3BucketParams,
}); });
}
res.set('content-type', contentType); setResponseHeader(event, 'content-type', contentType);
res.write(buffer); return buffer;
return res.end();
} }
})); });
export default router;

View File

@ -5,12 +5,10 @@ import { buildCalendar } from '#shared/calendar/calendar.ts';
import type { Calendar } from '#shared/calendar/helpers.ts'; import type { Calendar } from '#shared/calendar/helpers.ts';
import { PronounLibrary } from '#shared/classes.ts'; import { PronounLibrary } from '#shared/classes.ts';
import type { Pronoun, PronounGroup } from '#shared/classes.ts'; import type { Pronoun, PronounGroup } from '#shared/classes.ts';
import { buildDict } from '#shared/helpers';
import type { NounsData } from '#shared/nouns.ts'; import type { NounsData } from '#shared/nouns.ts';
import { Translator } from '#shared/translator.ts'; import { Translator } from '#shared/translator.ts';
import type { Config } from '~~/locale/config.ts'; import type { Config } from '~~/locale/config.ts';
import type { PronounData, PronounExamplesData } from '~~/locale/data.ts'; import type { PronounData, PronounExamplesData } from '~~/locale/data.ts';
import locales from '~~/locale/locales.ts';
import type { LocaleCode } from '~~/locale/locales.ts'; import type { LocaleCode } from '~~/locale/locales.ts';
import { loadSuml, loadTsv } from '~~/server/loader.ts'; import { loadSuml, loadTsv } from '~~/server/loader.ts';
import { getLocaleForUrl, getUrlForLocale } from '~~/server/src/domain.ts'; import { getLocaleForUrl, getUrlForLocale } from '~~/server/src/domain.ts';
@ -25,12 +23,6 @@ const setDefault = async <K, V>(map: Map<K, V>, key: K, supplier: () => Promise<
return computedValue; return computedValue;
}; };
export const localesDict = buildDict(function* () {
for (const locale of locales) {
yield [locale.code, locale];
}
});
export const getLocale = (event: H3Event): LocaleCode => { export const getLocale = (event: H3Event): LocaleCode => {
const query = getQuery(event); const query = getQuery(event);
if (typeof query.locale === 'string') { if (typeof query.locale === 'string') {

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 {

View File

@ -0,0 +1,211 @@
import type { Engine, LanguageCode, VoiceId } from '@aws-sdk/client-polly';
export type Voice = AwsPollyVoice | NarakeetVoice;
/**
* @see https://docs.aws.amazon.com/polly/latest/dg/voicelist.html
*/
export interface AwsPollyVoice {
/**
* human-readable name
*/
name: string;
/**
* text-to-speech provider (aws_polly is the default, if not specified)
*/
provider?: 'aws_polly';
/**
* language code
*/
language: LanguageCode;
/**
* voice name
*/
voice: VoiceId;
/**
* voice engine (default: 'standard')
*/
engine?: Engine;
}
export interface NarakeetVoice {
/**
* human-readable name
*/
name: string;
/**
* text-to-speech provider
*/
provider: 'narakeet';
/**
* language code
*/
language: string;
/**
* voice name
*/
voice: string;
}
const defineVoices = <T extends Record<string, Voice>>(voices: T) => voices as Readonly<Record<keyof T, Voice>>;
export const voices = defineVoices({
ar: {
name: 'العربية',
language: 'arb',
voice: 'Zeina',
engine: 'standard',
},
ae: {
name: '(الخليجية) العربية',
language: 'ar-AE',
voice: 'Hala',
engine: 'neural',
},
de: {
name: 'Deutsch',
language: 'de-DE',
voice: 'Vicki',
engine: 'standard',
},
gb: {
name: 'English (British)',
language: 'en-GB',
voice: 'Emma',
engine: 'neural',
},
us: {
name: 'English (American)',
language: 'es-US',
voice: 'Lupe',
engine: 'standard',
},
es: {
name: 'Español (España)',
language: 'es-ES',
voice: 'Lucia',
engine: 'standard',
},
mx: {
name: 'Español (México)',
language: 'es-MX',
voice: 'Mia',
engine: 'standard',
},
fi: {
name: 'Suomi',
language: 'fi-FI',
voice: 'Suvi',
engine: 'neural',
},
fr: {
name: 'Français (France)',
language: 'fr-FR',
voice: 'Lea',
engine: 'standard',
},
ca: {
name: 'Français (Canada)',
language: 'fr-CA',
voice: 'Gabrielle',
engine: 'neural',
},
bos: {
name: 'Босански',
language: 'bos',
voice: 'suada',
provider: 'narakeet',
},
hrv: {
name: 'Hrvatski',
language: 'hrv',
voice: 'jasna',
provider: 'narakeet',
},
srp: {
name: 'Српски',
language: 'srp',
voice: 'milica-latin',
provider: 'narakeet',
},
it: {
name: 'Italiano',
language: 'it-IT',
voice: 'Bianca',
engine: 'neural',
},
ja: {
name: '日本語',
language: 'ja-JP',
voice: 'Mizuki',
engine: 'standard',
},
ko: {
name: '한국어',
language: 'ko-KR',
voice: 'Seoyeon',
engine: 'neural',
},
nb: {
name: 'Norsk',
language: 'nb-NO',
voice: 'Liv',
engine: 'standard',
},
nl: {
name: 'Nederlands',
language: 'nl-NL',
voice: 'Ruben',
engine: 'standard',
},
pl: {
name: 'Polski',
language: 'pl-PL',
voice: 'Ewa',
engine: 'standard',
},
pt: {
name: 'Português (europeu)',
language: 'pt-PT',
voice: 'Cristiano',
engine: 'standard',
},
br: {
name: 'Português (brasileiro)',
language: 'pt-BR',
voice: 'Vitoria',
engine: 'standard',
},
ro: {
name: 'Română',
language: 'ro-RO',
voice: 'Carmen',
engine: 'standard',
},
ru: {
name: 'Русский',
language: 'ru-RU',
voice: 'Tatyana',
engine: 'standard',
},
se: {
name: 'Svenska',
language: 'sv-SE',
voice: 'Astrid',
engine: 'standard',
},
tr: {
name: 'Türkçe',
language: 'tr-TR',
voice: 'Filiz',
engine: 'standard',
},
cn: {
name: '中文(普通话)',
language: 'cmn-CN',
voice: 'Zhiyu',
engine: 'standard',
},
});
export type VoiceKey = keyof typeof voices;