mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-22 03:57:47 -04:00
Merge branch 'configurable-voice-p' into 'main'
configurable voices See merge request PronounsPage/PronounsPage!652
This commit is contained in:
commit
d5656ef739
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
<span class="text-nowrap">
|
||||
<span v-if="text" class="text-pronunciation">
|
||||
<span v-if="text" class="text-secondary-emphasis fw-normal">
|
||||
{{ pronunciation }}
|
||||
</span>
|
||||
<PronunciationSpeaker
|
||||
v-for="voice in voices"
|
||||
:key="voice"
|
||||
:pronunciation="pronunciation"
|
||||
:voice="voice"
|
||||
:pronunciation
|
||||
:voice
|
||||
:show-voice-label="voices.length > 1"
|
||||
class="btn btn-sm btn-link px-1 py-0"
|
||||
/>
|
||||
</span>
|
||||
</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>
|
||||
|
@ -1,5 +1,58 @@
|
||||
<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 pronounciationModelValue = defineModel<string | null>('pronounciation', { required: true });
|
||||
const voiceModelValue = defineModel<VoiceKey | null>('voice', { required: true });
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const rawPronunciation = computed({
|
||||
get(): string {
|
||||
if (pronounciationModelValue.value) {
|
||||
const phonemes = pronounciationModelValue.value.substring(1, pronounciationModelValue.value.length - 1);
|
||||
return unescapePronunciationString(phonemes);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(rawPronunciation: string) {
|
||||
let pronunciation;
|
||||
if (rawPronunciation) {
|
||||
pronunciation = `/${escapePronunciationString(rawPronunciation)}/`;
|
||||
} else {
|
||||
pronunciation = null;
|
||||
}
|
||||
|
||||
pronounciationModelValue.value = pronunciation;
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<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"
|
||||
@ -8,62 +61,28 @@
|
||||
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.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>
|
||||
|
||||
<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';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: { default: null, type: String as PropType<string | null> },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup() {
|
||||
return {
|
||||
config: useConfig(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
rawPronunciation: {
|
||||
get(): string {
|
||||
if (this.modelValue) {
|
||||
const phonemes = this.modelValue.substring(1, this.modelValue.length - 1);
|
||||
return unescapePronunciationString(phonemes);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
set(rawPronunciation: string) {
|
||||
let pronunciation;
|
||||
if (rawPronunciation) {
|
||||
pronunciation = `/${escapePronunciationString(rawPronunciation)}/`;
|
||||
} else {
|
||||
pronunciation = null;
|
||||
}
|
||||
|
||||
this.$emit('update:modelValue', pronunciation);
|
||||
},
|
||||
},
|
||||
voices(): string[] {
|
||||
if (this.config.pronunciation?.enabled) {
|
||||
return Object.keys(this.config.pronunciation.voices);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pronunciation?: string | null;
|
||||
voice: string;
|
||||
button?: boolean;
|
||||
voice: VoiceKey;
|
||||
showVoiceLabel?: boolean;
|
||||
}>(), {
|
||||
pronunciation: null,
|
||||
});
|
||||
@ -56,18 +58,21 @@ const icon = computed((): string => {
|
||||
});
|
||||
|
||||
const config = useConfig();
|
||||
const name = computed((): string | null => {
|
||||
const voiceLabel = computed((): string | null => {
|
||||
if (!props.showVoiceLabel) {
|
||||
return null;
|
||||
}
|
||||
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]) {
|
||||
// don’t show voice name if it is considered the main voice for this locale
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.voice;
|
||||
return props.voice.toUpperCase();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -81,6 +86,6 @@ const name = computed((): string | null => {
|
||||
target="_blank"
|
||||
@click.prevent="pronounce"
|
||||
>
|
||||
<Icon :v="icon" /><sub v-if="name">{{ name }}</sub>
|
||||
<Icon :v="icon" /><sub v-if="voiceLabel">{{ voiceLabel }}</sub>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
@ -460,18 +460,24 @@ const propagateChanged = (field: string, checked: boolean): void => {
|
||||
<T>profile.names</T>
|
||||
</template>
|
||||
<template #names>
|
||||
<p v-if="$te('profile.namesInfo')" class="small text-muted">
|
||||
<T>profile.namesInfo</T>
|
||||
</p>
|
||||
<div class="alert alert-info">
|
||||
<p class="small mb-0">
|
||||
<Icon v="info-circle" />
|
||||
<T>profile.namesInfo</T>
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
|
@ -99,11 +99,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
GB:
|
||||
language: 'en-GB'
|
||||
voice: 'Emma'
|
||||
engine: 'neural'
|
||||
voices: ['gb']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -726,8 +726,17 @@ user:
|
||||
profile:
|
||||
description: 'Description'
|
||||
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:
|
||||
ipa: 'Pronunciation using IPA'
|
||||
voice:
|
||||
without: '(without voice)'
|
||||
pronouns: 'Pronouns'
|
||||
pronounsInfo: >
|
||||
You can enter a <strong>pronoun</strong> (eg. “they” or “she/her”)
|
||||
|
@ -51,15 +51,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
AR:
|
||||
language: 'arb'
|
||||
voice: 'Zeina'
|
||||
engine: 'standard'
|
||||
AE:
|
||||
language: 'ar-AE'
|
||||
voice: 'Hala'
|
||||
engine: 'neural'
|
||||
voices: ['ar', 'ae']
|
||||
|
||||
sources:
|
||||
enabled: false
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type { Engine, LanguageCode, VoiceId } from '@aws-sdk/client-polly';
|
||||
|
||||
import type { Category } from '#shared/classes.ts';
|
||||
import type { GrammarTablesDefinition } from '#shared/language/grammarTables.ts';
|
||||
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
|
||||
import type { LocaleCode } from '~~/locale/locales.ts';
|
||||
|
||||
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
|
||||
*/
|
||||
ipa?: boolean;
|
||||
voices: Record<string, PronunciationVoiceConfig>;
|
||||
}
|
||||
|
||||
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;
|
||||
voices: VoiceKey[];
|
||||
}
|
||||
|
||||
interface SourcesConfig {
|
||||
|
@ -488,11 +488,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
DE:
|
||||
language: 'de-DE'
|
||||
voice: 'Vicki'
|
||||
engine: 'standard'
|
||||
voices: ['de']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -842,12 +842,21 @@ user:
|
||||
profile:
|
||||
description: 'Beschreibung'
|
||||
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:
|
||||
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“)
|
||||
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.
|
||||
pronounsNotFound: 'Nicht erkennbares Format. Bitte beachte die oben genannte Anweisung.'
|
||||
words: 'Wörter'
|
||||
|
@ -149,11 +149,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
GB:
|
||||
language: 'en-GB'
|
||||
voice: 'Emma'
|
||||
engine: 'neural'
|
||||
voices: ['gb']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -942,8 +942,17 @@ user:
|
||||
profile:
|
||||
description: 'Description'
|
||||
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:
|
||||
ipa: 'Pronunciation using IPA'
|
||||
voice:
|
||||
without: '(without voice)'
|
||||
pronouns: 'Pronouns'
|
||||
pronounsInfo: >
|
||||
You can enter a <strong>pronoun</strong> (eg. “they” or “she/her”)
|
||||
|
@ -97,7 +97,6 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: false
|
||||
voices: {}
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -128,15 +128,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
ES:
|
||||
language: 'es-ES'
|
||||
voice: 'Lucia'
|
||||
engine: 'standard'
|
||||
MX:
|
||||
language: 'es-MX'
|
||||
voice: 'Mia'
|
||||
engine: 'standard'
|
||||
voices: ['es', 'mx']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -37,11 +37,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: false
|
||||
voices:
|
||||
FI:
|
||||
language: 'fi-FI'
|
||||
voice: 'Suvi'
|
||||
engine: 'neural'
|
||||
voices: ['fi']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -133,11 +133,6 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: false
|
||||
voices:
|
||||
GB:
|
||||
language: 'en-GB'
|
||||
voice: 'Emma'
|
||||
engine: 'neural'
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -65,15 +65,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
FR:
|
||||
language: 'fr-FR'
|
||||
voice: 'Lea'
|
||||
engine: 'standard'
|
||||
CA:
|
||||
language: 'fr-CA'
|
||||
voice: 'Gabrielle'
|
||||
engine: 'neural'
|
||||
voices: ['fr', 'ca']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -79,8 +79,7 @@ pronouns:
|
||||
shortMorphemes: 3
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices: {}
|
||||
enabled: false
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -98,19 +98,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
BOS:
|
||||
language: 'bos'
|
||||
voice: 'suada'
|
||||
provider: 'narakeet'
|
||||
HRV:
|
||||
language: 'hrv'
|
||||
voice: 'jasna'
|
||||
provider: 'narakeet'
|
||||
SRP:
|
||||
language: 'srp'
|
||||
voice: 'milica-latin'
|
||||
provider: 'narakeet'
|
||||
voices: ['bos', 'hrv', 'srp']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -44,12 +44,7 @@ pronouns:
|
||||
others: 'Other pronouns'
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
GB:
|
||||
language: 'en-GB'
|
||||
voice: 'Emma'
|
||||
engine: 'neural'
|
||||
enabled: false
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -324,11 +324,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
IT:
|
||||
language: 'it-IT'
|
||||
voice: 'Bianca'
|
||||
engine: 'neural'
|
||||
voices: ['it']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -45,11 +45,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
JA:
|
||||
language: 'ja-JP'
|
||||
voice: 'Mizuki'
|
||||
engine: 'standard'
|
||||
voices: ['ja']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -40,11 +40,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
KO:
|
||||
language: 'ko-KR'
|
||||
voice: 'Seoyeon'
|
||||
engine: 'neural'
|
||||
voices: ['ko']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -120,11 +120,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
NO:
|
||||
language: 'nb-NO'
|
||||
voice: 'Liv'
|
||||
engine: 'standard'
|
||||
voices: ['no']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -71,11 +71,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
GB:
|
||||
language: 'nl-NL'
|
||||
voice: 'Ruben'
|
||||
engine: 'standard'
|
||||
voices: ['nl']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -121,11 +121,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
NO:
|
||||
language: 'nb-NO'
|
||||
voice: 'Liv'
|
||||
engine: 'standard'
|
||||
voices: ['no']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -322,11 +322,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
PL:
|
||||
language: 'pl-PL'
|
||||
voice: 'Ewa'
|
||||
engine: 'standard'
|
||||
voices: ['pl']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -81,15 +81,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
PT:
|
||||
language: 'pt-PT'
|
||||
voice: 'Cristiano'
|
||||
engine: 'standard'
|
||||
BR:
|
||||
language: 'pt-BR'
|
||||
voice: 'Vitoria'
|
||||
engine: 'standard'
|
||||
voices: ['pt', 'br']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -113,11 +113,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
RO:
|
||||
language: 'ro-RO'
|
||||
voice: 'Carmen'
|
||||
engine: 'standard'
|
||||
voices: ['ro']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -162,11 +162,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
GB:
|
||||
language: 'ru-RU'
|
||||
voice: 'Tatyana'
|
||||
engine: 'standard'
|
||||
voices: ['ru']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -50,11 +50,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
SE:
|
||||
language: 'sv-SE'
|
||||
voice: 'Astrid'
|
||||
engine: 'standard'
|
||||
voices: ['se']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -29,11 +29,7 @@ pronouns:
|
||||
pronunciation:
|
||||
enabled: false
|
||||
ipa: true
|
||||
voices:
|
||||
TOK:
|
||||
language: 'es-US'
|
||||
voice: 'Lupe'
|
||||
engine: 'standard'
|
||||
voices: ['us']
|
||||
|
||||
sources:
|
||||
enabled: false
|
||||
|
@ -11,11 +11,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
TR:
|
||||
language: 'tr-TR'
|
||||
voice: 'Filiz'
|
||||
engine: 'standard'
|
||||
voices: ['tr']
|
||||
|
||||
sources:
|
||||
enabled: false
|
||||
|
@ -83,7 +83,6 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: false
|
||||
voices: {}
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -35,7 +35,6 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: false
|
||||
voices: {}
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
@ -35,11 +35,7 @@ pronouns:
|
||||
|
||||
pronunciation:
|
||||
enabled: true
|
||||
voices:
|
||||
CN:
|
||||
language: 'cmn-CN'
|
||||
voice: 'Zhiyu'
|
||||
engine: 'standard'
|
||||
voices: ['cn']
|
||||
|
||||
sources:
|
||||
enabled: true
|
||||
|
20
migrations/095-profile-pronunciation-voice.sql
Normal file
20
migrations/095-profile-pronunciation-voice.sql
Normal 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
|
@ -2,31 +2,21 @@ import { Polly } from '@aws-sdk/client-polly';
|
||||
import { NoSuchKey } from '@aws-sdk/client-s3';
|
||||
import type { S3 } from '@aws-sdk/client-s3';
|
||||
import type { NodeJsClient } from '@smithy/types';
|
||||
import { Router } from 'express';
|
||||
import { getH3Event } from 'h3-express';
|
||||
import { Base64 } from 'js-base64';
|
||||
import sha1 from 'sha1';
|
||||
|
||||
import { s3, awsConfig, s3BucketParams } from '../cloudServices.ts';
|
||||
|
||||
import {
|
||||
convertPronunciationStringToSsml,
|
||||
convertPronunciationStringToNarakeetFormat,
|
||||
handleErrorAsync,
|
||||
sha256,
|
||||
} from '#shared/helpers.ts';
|
||||
import type {
|
||||
PronunciationVoiceConfig,
|
||||
AwsPollyPronunciationVoiceConfig,
|
||||
NarakeetPronunciationVoiceConfig,
|
||||
} from '~~/locale/config.ts';
|
||||
import { getLocale, loadConfig, localesDict } from '~~/server/data.ts';
|
||||
|
||||
const router = Router();
|
||||
import { voices } from '#shared/pronunciation/voices.ts';
|
||||
import type { VoiceKey, AwsPollyVoice, NarakeetVoice, Voice } from '#shared/pronunciation/voices.ts';
|
||||
import { s3, awsConfig, s3BucketParams } from '~~/server/cloudServices.ts';
|
||||
|
||||
type ProviderKey = 'aws_polly' | 'narakeet';
|
||||
interface Provider {
|
||||
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> = {
|
||||
@ -34,7 +24,7 @@ const providers: Record<ProviderKey, Provider> = {
|
||||
tokenised(text: string): string {
|
||||
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 pollyResponse = await polly.synthesizeSpeech({
|
||||
@ -56,7 +46,7 @@ const providers: Record<ProviderKey, Provider> = {
|
||||
tokenised(text: string): string {
|
||||
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 response = await fetch(url, {
|
||||
@ -81,29 +71,40 @@ const providers: Record<ProviderKey, Provider> = {
|
||||
},
|
||||
};
|
||||
|
||||
router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res) => {
|
||||
const text = Base64.decode(req.params.pronunciation);
|
||||
export default defineEventHandler(async (event) => {
|
||||
const text = Base64.decode(getRouterParam(event, 'pronunciation')!);
|
||||
|
||||
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 config = await loadConfig(locale);
|
||||
|
||||
const voice: PronunciationVoiceConfig | undefined = config.pronunciation?.voices?.[req.params.voice];
|
||||
if (!voice) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
const voiceKey = getRouterParam(event, 'voice');
|
||||
if (voiceKey === undefined || !(voiceKey in voices)) {
|
||||
throw createError({
|
||||
status: 404,
|
||||
statusMessage: 'Not Found',
|
||||
});
|
||||
}
|
||||
|
||||
const voice = voices[voiceKey as VoiceKey];
|
||||
const provider = providers[(voice.provider || 'aws_polly') as ProviderKey];
|
||||
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 {
|
||||
const s3Response = await (s3 as NodeJsClient<S3>).getObject({ Key: key, ...s3BucketParams });
|
||||
res.set('content-type', s3Response.ContentType);
|
||||
return s3Response.Body!.pipe(res);
|
||||
setResponseHeader(event, 'content-type', s3Response.ContentType);
|
||||
return s3Response.Body;
|
||||
} catch (error) {
|
||||
if (!(error instanceof NoSuchKey)) {
|
||||
throw error;
|
||||
@ -111,20 +112,15 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
|
||||
|
||||
const [buffer, contentType] = await provider.generate(tokenised, voice);
|
||||
|
||||
if (localesDict[locale]?.published) {
|
||||
// don't populate the cache for locales still under construction
|
||||
await s3.putObject({
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
...s3BucketParams,
|
||||
});
|
||||
}
|
||||
// don't populate the cache for locales still under construction
|
||||
await s3.putObject({
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
...s3BucketParams,
|
||||
});
|
||||
|
||||
res.set('content-type', contentType);
|
||||
res.write(buffer);
|
||||
return res.end();
|
||||
setResponseHeader(event, 'content-type', contentType);
|
||||
return buffer;
|
||||
}
|
||||
}));
|
||||
|
||||
export default router;
|
||||
});
|
@ -5,12 +5,10 @@ import { buildCalendar } from '#shared/calendar/calendar.ts';
|
||||
import type { Calendar } from '#shared/calendar/helpers.ts';
|
||||
import { PronounLibrary } 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 { Translator } from '#shared/translator.ts';
|
||||
import type { Config } from '~~/locale/config.ts';
|
||||
import type { PronounData, PronounExamplesData } from '~~/locale/data.ts';
|
||||
import locales from '~~/locale/locales.ts';
|
||||
import type { LocaleCode } from '~~/locale/locales.ts';
|
||||
import { loadSuml, loadTsv } from '~~/server/loader.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;
|
||||
};
|
||||
|
||||
export const localesDict = buildDict(function* () {
|
||||
for (const locale of locales) {
|
||||
yield [locale.code, locale];
|
||||
}
|
||||
});
|
||||
|
||||
export const getLocale = (event: H3Event): LocaleCode => {
|
||||
const query = getQuery(event);
|
||||
if (typeof query.locale === 'string') {
|
||||
|
@ -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)),
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
211
shared/pronunciation/voices.ts
Normal file
211
shared/pronunciation/voices.ts
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user