refactor: list available voices independent of locale configs

This commit is contained in:
Valentyne Stigloher 2025-09-14 19:47:26 +02:00
parent 0053e2f52c
commit 40d7f6a7d8
33 changed files with 274 additions and 277 deletions

View File

@ -1,3 +1,23 @@
<script setup lang="ts">
import useConfig from '../composables/useConfig.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
defineProps<{
pronunciation: string;
text?: boolean;
}>();
const config = useConfig();
const voices = computed((): VoiceKey[] => {
if (!config.pronunciation?.enabled) {
return [];
}
return config.pronunciation.voices;
});
</script>
<template>
<span class="text-nowrap">
<span v-if="text" class="text-pronunciation">
@ -13,33 +33,6 @@
</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";

View File

@ -1,3 +1,42 @@
<script setup lang="ts">
import useConfig from '../composables/useConfig.ts';
import { escapePronunciationString, unescapePronunciationString } from '#shared/helpers.ts';
import type { VoiceKey } from '#shared/pronunciation/voices.ts';
const modelValue = defineModel<string | null>({ required: true });
const config = useConfig();
const rawPronunciation = computed({
get(): string {
if (modelValue.value) {
const phonemes = modelValue.value.substring(1, modelValue.value.length - 1);
return unescapePronunciationString(phonemes);
} else {
return '';
}
},
set(rawPronunciation: string) {
let pronunciation;
if (rawPronunciation) {
pronunciation = `/${escapePronunciationString(rawPronunciation)}/`;
} else {
pronunciation = null;
}
modelValue.value = pronunciation;
},
});
const voices = computed((): VoiceKey[] => {
if (!config.pronunciation?.enabled) {
return [];
}
return config.pronunciation.voices;
});
</script>
<template>
<div class="input-group input-group-sm w-auto">
<span class="input-group-text">/</span>
@ -17,53 +56,3 @@
/>
</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>

View File

@ -1,9 +1,11 @@
<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;
voice: VoiceKey;
button?: boolean;
}>(), {
pronunciation: null,
@ -58,16 +60,16 @@ const icon = computed((): string => {
const config = useConfig();
const name = computed((): string | 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]) {
// dont show voice name if it is considered the main voice for this locale
return null;
}
return props.voice;
return props.voice.toUpperCase();
});
</script>

View File

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

View File

@ -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

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 { 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 {

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,11 +14,8 @@ import {
convertPronunciationStringToNarakeetFormat,
handleErrorAsync,
} from '#shared/helpers.ts';
import type {
PronunciationVoiceConfig,
AwsPollyPronunciationVoiceConfig,
NarakeetPronunciationVoiceConfig,
} from '~~/locale/config.ts';
import { voices } from '#shared/pronunciation/voices.ts';
import type { VoiceKey, AwsPollyVoice, NarakeetVoice, Voice } from '#shared/pronunciation/voices.ts';
import { getLocale, loadConfig } from '~~/server/data.ts';
const router = Router();
@ -26,7 +23,7 @@ const router = Router();
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 +31,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 +53,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/m4a?voice=${voice.voice}`;
const response = await fetch(url, {
@ -91,11 +88,11 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
const locale = getLocale(getH3Event(req));
const config = await loadConfig(locale);
const voice: PronunciationVoiceConfig | undefined = config.pronunciation?.voices?.[req.params.voice];
if (!voice) {
if (!(req.params.voice in voices)) {
return res.status(404).json({ error: 'Not found' });
}
const voice = voices[req.params.voice 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`;

View File

@ -0,0 +1,177 @@
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 {
/**
* 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 {
/**
* 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: {
language: 'arb',
voice: 'Zeina',
engine: 'standard',
},
ae: {
language: 'ar-AE',
voice: 'Hala',
engine: 'neural',
},
de: {
language: 'de-DE',
voice: 'Vicki',
engine: 'standard',
},
gb: {
language: 'en-GB',
voice: 'Emma',
engine: 'neural',
},
es: {
language: 'es-ES',
voice: 'Lucia',
engine: 'standard',
},
mx: {
language: 'es-MX',
voice: 'Mia',
engine: 'standard',
},
fi: {
language: 'fi-FI',
voice: 'Suvi',
engine: 'neural',
},
fr: {
language: 'fr-FR',
voice: 'Lea',
engine: 'standard',
},
ca: {
language: 'fr-CA',
voice: 'Gabrielle',
engine: 'neural',
},
bos: {
language: 'bos',
voice: 'suada',
provider: 'narakeet',
},
hrv: {
language: 'hrv',
voice: 'jasna',
provider: 'narakeet',
},
srp: {
language: 'srp',
voice: 'milica-latin',
provider: 'narakeet',
},
it: {
language: 'it-IT',
voice: 'Bianca',
engine: 'neural',
},
ja: {
language: 'ja-JP',
voice: 'Mizuki',
engine: 'standard',
},
ko: {
language: 'ko-KR',
voice: 'Seoyeon',
engine: 'neural',
},
nb: {
language: 'nb-NO',
voice: 'Liv',
engine: 'standard',
},
nl: {
language: 'nl-NL',
voice: 'Ruben',
engine: 'standard',
},
pl: {
language: 'pl-PL',
voice: 'Ewa',
engine: 'standard',
},
pt: {
language: 'pt-PT',
voice: 'Cristiano',
engine: 'standard',
},
br: {
language: 'pt-BR',
voice: 'Vitoria',
engine: 'standard',
},
ro: {
language: 'ro-RO',
voice: 'Carmen',
engine: 'standard',
},
ru: {
language: 'ru-RU',
voice: 'Tatyana',
engine: 'standard',
},
se: {
language: 'sv-SE',
voice: 'Astrid',
engine: 'standard',
},
tok: {
language: 'es-US',
voice: 'Lupe',
engine: 'standard',
},
tr: {
language: 'tr-TR',
voice: 'Filiz',
engine: 'standard',
},
cn: {
language: 'cmn-CN',
voice: 'Zhiyu',
engine: 'standard',
},
});
export type VoiceKey = keyof typeof voices;