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> <template>
<span class="text-nowrap"> <span class="text-nowrap">
<span v-if="text" class="text-pronunciation"> <span v-if="text" class="text-pronunciation">
@ -13,33 +33,6 @@
</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> <style lang="scss" scoped>
@import "~/assets/variables"; @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> <template>
<div class="input-group input-group-sm w-auto"> <div class="input-group input-group-sm w-auto">
<span class="input-group-text">/</span> <span class="input-group-text">/</span>
@ -17,53 +56,3 @@
/> />
</div> </div>
</template> </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"> <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; button?: boolean;
}>(), { }>(), {
pronunciation: null, pronunciation: null,
@ -58,16 +60,16 @@ const icon = computed((): string => {
const config = useConfig(); const config = useConfig();
const name = computed((): string | null => { const name = computed((): string | 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>

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

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

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

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

@ -289,11 +289,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: ['tok']
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

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