Merge branch '334-name-pronunciation' into 'main'

Name pronunciation

See merge request PronounsPage/PronounsPage!387
This commit is contained in:
Valentyne Stigloher 2024-01-11 15:22:39 +00:00
commit 44d28fc78b
19 changed files with 284 additions and 96 deletions

View File

@ -7,21 +7,13 @@
<small v-if="link">
(<nuxt-link :to="'/' + pronoun.canonicalName"><Spelling escape :text="pronoun.canonicalName"/></nuxt-link>)
</small>
<template v-if="config.pronunciation.enabled && pronunciation && pronoun.pronounceable && example.pronounce(pronoun)">
<a v-for="(pLink, name) in pronunciationLinks"
class="mr-2"
dir="ltr"
:href="pLink"
@click.prevent="pronounce(pLink)">
<Icon v="volume"/><sub v-if="name">{{name}}</sub>
</a>
</template>
<Pronunciation v-if="pronunciation && pronoun.pronounceable && example.toPronunciationString(pronoun)"
:pronunciation="example.toPronunciationString(pronoun)"
/>
</span>
</template>
<script>
import { pronouns } from '../src/data';
export default {
props: {
example: { required: true },
@ -30,42 +22,5 @@
link: { type: Boolean },
pronunciation: { type: Boolean },
},
methods: {
pronounce(link) {
const sound = new Audio(link);
sound.play();
}
},
computed: {
pronounBase() {
const name = this.pronoun.name();
for (let key in pronouns) {
if (!pronouns.hasOwnProperty(key)) { continue; }
if (key === name) {
return key;
}
for (let alias of pronouns[key].aliases) {
if (alias === name) {
return key;
}
}
}
return null;
},
pronounToString() {
return this.pronounBase && pronouns[this.pronounBase].equals(this.pronoun) ? this.pronounBase : this.pronoun.toString();
},
pronunciationLinks() {
const justOne = Object.keys(this.config.pronunciation.voices).length === 1;
const links = {};
for (let country in this.config.pronunciation.voices) {
if (!this.config.pronunciation.voices.hasOwnProperty(country)) { continue; }
links[justOne ? '' : country] = `/api/pronounce/${country}/${this.pronounToString}?example=${encodeURIComponent(this.example.toString())}`;
}
return links;
}
}
}
</script>

View File

@ -1,9 +1,9 @@
<template>
<span v-if="pronoun.getMorpheme(morpheme, counter)">
<Morpheme :pronoun="pronoun" :morpheme="morpheme" :counter="counter" :prepend="prepend" :append="append"/>
<span v-if="config.pronunciation.enabled && pronoun.pronounceable && pronoun.getPronunciation(morpheme, counter) && !pronoun.getPronunciation(morpheme, counter).startsWith('=')" class="text-muted">
/{{prependPr}}{{pronoun.getPronunciation(morpheme, counter)}}{{appendPr}}/
</span>
<Pronunciation v-if="pronoun.pronounceable && pronoun.getPronunciation(morpheme, counter) && !pronoun.getPronunciation(morpheme, counter).startsWith('=')"
:pronunciation="`/${prependPr}${pronoun.getPronunciation(morpheme, counter)}${appendPr}/`" text
/>
</span>
</template>

View File

@ -5,6 +5,7 @@
</Tooltip>
<nuxt-link v-if="link" :to="link" :class="`colour-${op.colour || 'default'}`"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :markdown="markdown" :text="word"/></span>
<Pronunciation v-if="pronunciation" :pronunciation="pronunciation" text/>
</span>
</template>
@ -14,6 +15,7 @@
export default {
props: {
word: { required: true },
pronunciation: { default: null, type: String },
opinion: { required: true },
link: {},
escape: { type: Boolean, 'default': () => true },

View File

@ -1,11 +1,12 @@
<template>
<ListInput v-model="v" :prototype="{value: '', opinion: 'meh'}" :group="group" :maxitems="maxitems">
<ListInput v-model="v" :prototype="prototype" :group="group" :maxitems="maxitems">
<template v-slot="s">
<button type="button" :class="['btn', 'btn-outline-secondary', showOpinionSelector === s.i ? 'btn-secondary text-white border' : (validate(s.val) ? 'btn-outline-danger' : '')]"
@click="showOpinionSelector = showOpinionSelector === s.i ? false : s.i">
<Icon :v="getIcon(s.val.opinion)"/>
</button>
<input v-model="s.val.value" :class="['form-control', 'mw-input', validate(s.val) ? 'border-danger' : '']" @keyup="s.update(s.val)" required :maxlength="maxlength"/>
<slot name="additional" :val="s.val"/>
<div v-if="showOpinionSelector === s.i" class="bg-light border rounded hanging shadow shadow-lg">
<ul class="list-unstyled icons-list p-1 text-center mb-0">
@ -41,6 +42,7 @@
export default {
props: {
value: {},
prototype: { 'default': () => { return { value: '', opinion: 'meh' } } },
group: {},
validation: {},
customOpinions: { 'default': () => { return [] }},

View File

@ -76,6 +76,7 @@
<ExpandableList :values="profile.names" :limit="16" class="list-unstyled" :isStatic="isStatic" :expand="expandLinks">
<template v-slot="s">
<Opinion :word="convertName(s.el.value)" :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"
:customOpinions="profile.opinions"/>
</template>

View File

@ -0,0 +1,41 @@
<template>
<span class="pronunciation">
<span v-if="text" class="text-pronunciation">
{{ pronunciation }}
</span>
<PronunciationSpeaker v-for="voice in voices" :key="voice"
:pronunciation="pronunciation" :voice="voice"
/>
</span>
</template>
<script>
export default {
props: {
pronunciation: { required: true, type: String },
text: { default: false, type: Boolean },
},
computed: {
voices() {
if (this.config.pronunciation.enabled) {
return Object.keys(this.config.pronunciation.voices);
} else {
return [];
}
},
},
}
</script>
<style lang="scss" scoped>
@import "assets/variables";
.pronunciation {
white-space: nowrap;
}
.text-pronunciation {
font-weight: normal;
color: var(--#{$prefix}secondary-color);
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<div class="input-group input-group-sm w-auto">
<span class="input-group-text">/</span>
<input class="form-control mw-input" v-model="rawPronunciation" :placeholder="$t('profile.pronunciation.ipa')" maxlength="255"/>
<span class="input-group-text">/</span>
<PronunciationSpeaker v-for="voice in voices" :key="voice"
class="btn btn-outline-secondary"
:pronunciation="value" :voice="voice"/>
</div>
</template>
<script>
import { escapePronunciationString, unescapePronunciationString } from '../src/helpers.js';
export default {
props: {
value: { default: null, type: String },
},
computed: {
rawPronunciation: {
get() {
if (this.value) {
const phonemes = this.value.substring(1, this.value.length - 1);
return unescapePronunciationString(phonemes);
} else {
return '';
}
},
set(rawPronunciation) {
let pronunciation;
if (rawPronunciation) {
pronunciation = `/${escapePronunciationString(rawPronunciation)}/`;
} else {
pronunciation = null;
}
this.$emit('input', pronunciation);
},
},
voices() {
if (this.config.pronunciation.enabled) {
return Object.keys(this.config.pronunciation.voices);
} else {
return [];
}
},
},
};
</script>

View File

@ -0,0 +1,43 @@
<template>
<a :class="['mr-2', !pronunciation ? 'disabled' : '']" dir="ltr"
:href="pronunciationLink" @click.prevent="pronounce()"
>
<Icon v="volume"/><sub v-if="name">{{ name }}</sub>
</a>
</template>
<script>
export default {
props: {
pronunciation: { default: null, type: String },
voice: { required: true, type: String },
},
computed: {
pronunciationLink() {
return `/api/pronounce/${this.voice}/${encodeURIComponent(this.pronunciation)}`;
},
name() {
let voices;
if (this.config.pronunciation.enabled) {
voices = Object.keys(this.config.pronunciation.voices);
} else {
voices = null;
}
if (this.config.pronunciation.enabled && voices.length === 1 &&
this.voice === voices[0]) {
// dont show voice name if it is considered the main voice for this locale
return null;
} else {
return this.voice;
}
},
},
methods: {
pronounce() {
const sound = new Audio(this.pronunciationLink);
sound.play();
},
},
}
</script>

View File

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

View File

@ -577,6 +577,8 @@ user:
profile:
description: 'Beschreibung'
names: 'Namen'
pronunciation:
ipa: 'Aussprache in IPA'
pronouns: 'Pronomen'
pronounsInfo: >
Du kannst entweder ein <strong>Pronomen</strong> (z.B. „sier“ oder „sie/ihr“) oder einen <strong>Link</strong> (z.B. „https://de.pronouns.page/dey“)

View File

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

View File

@ -161,4 +161,5 @@ export default [
'profile.calendar.customEvents.validation.missingDate',
'profile.calendar.customEvents.validation.invalidDate',
'profile.calendar.publicEvents.header',
'profile.pronunciation.ipa',
];

View File

@ -239,12 +239,12 @@ export default {
use: 'yaml-loader',
});
config.module.rules.push({
test: /.js/,
test: /\.js$/,
loader: 'string-replace-loader',
options: {
// To load .json files inside of .js files of type module in a node environment,
// one has to either load from the filesystem or via a created require().
// While a load vie filesystem is very unfriendly to webpack,
// While a load via filesystem is very unfriendly to webpack,
// the explicit creation of a require() function can be removed.
// This probably gets replaced in the future by a `import from with { type: 'json' }`
// statement, which is currently (2023-12) experimental in node and not well supported in webpack.
@ -261,12 +261,8 @@ export default {
],
},
});
config.module.rules.push({
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
});
},
transpile: ['markdown-it'],
},
env: {
ENV: process.env.ENV,

View File

@ -110,7 +110,12 @@
<p v-if="$te('profile.namesInfo')" class="small text-muted">
<T>profile.namesInfo</T>
</p>
<OpinionListInput v-model="names" :customOpinions="opinions" :maxitems="128" :maxlength="config.profile.longNames ? 255 : 32"/>
<OpinionListInput v-model="names" :prototype="{ value: '', opinion: 'meh', pronunciation: null }"
:customOpinions="opinions" :maxitems="128" :maxlength="config.profile.longNames ? 255 : 32">
<template v-slot:additional="s">
<PronunciationInput v-model="s.val.pronunciation"/>
</template>
</OpinionListInput>
<InlineMarkdownInstructions v-model="markdown"/>
<PropagateCheckbox field="names" :before="beforeChanges.names" :after="names" v-if="otherProfiles > 0" @change="propagateChanged"/>
</template>

View File

@ -255,7 +255,9 @@ const fetchProfiles = async (db, username, self, opts = undefined) => {
const profile_obj = {
opinions: propv("opinions", () => JSON.parse(profile.opinions)),
names: propv("names", () => JSON.parse(profile.names)),
names: propv("names", () => {
return JSON.parse(profile.names).map((name) => { return { pronunciation: null, ...name }});
}),
pronouns: propv("pronouns", () => JSON.parse(profile.pronouns)),
description: propv("description", () => profile.description),
age: propv("age", () => calcAge(profile.birthday)),

View File

@ -1,9 +1,6 @@
import { Router } from 'express';
import { loadTsv } from '../loader.js';
import { buildPronoun, parsePronouns } from '../../src/buildPronoun.js';
import { Example } from '../../src/classes.js';
import sha1 from 'sha1';
import { handleErrorAsync } from '../../src/helpers.js';
import { convertPronunciationStringToSsml, handleErrorAsync } from '../../src/helpers.js';
import awsConfig from '../aws.js';
import Polly from 'aws-sdk/clients/polly.js';
@ -11,28 +8,10 @@ import S3 from 'aws-sdk/clients/s3.js';
const router = Router();
router.get('/pronounce/:voice/:pronoun*', handleErrorAsync(async (req, res) => {
const pronounString = req.params.pronoun + req.params[0];
const pronoun = buildPronoun(
parsePronouns(loadTsv('pronouns/pronouns')),
pronounString,
);
router.get('/pronounce/:voice/*', handleErrorAsync(async (req, res) => {
const text = req.params[0];
if (!pronoun || !req.query.example) {
return res.status(404).json({error: 'Not found'});
}
let [singular, plural, isHonorific] = req.query.example.split('|');
const example = new Example(
Example.parse(singular),
Example.parse(plural || singular),
!!parseInt(isHonorific || '0'),
)
const text = example.pronounce(pronoun);
// quick length check to avoid abuse. remove SSML tags but keep both tag value and attributes
if (!text || text.replace(/<[^ ]+/g, '').replace('>', '').length > 256) {
if (!text || text.length > 256) {
return res.status(404).json({error: 'Not found'});
}
@ -44,7 +23,8 @@ router.get('/pronounce/:voice/:pronoun*', handleErrorAsync(async (req, res) => {
const s3 = new S3(awsConfig);
const polly = new Polly(awsConfig);
const key = `pronunciation/${global.config.locale}-${req.params.voice}/${pronounString}/${sha1(text)}.mp3`;
const ssml = convertPronunciationStringToSsml(text);
const key = `pronunciation/${global.config.locale}-${req.params.voice}/${sha1(ssml)}.mp3`;
try {
const s3getResponse = await s3.getObject({Key: key}).promise();
@ -52,7 +32,7 @@ router.get('/pronounce/:voice/:pronoun*', handleErrorAsync(async (req, res) => {
} catch {
const pollyResponse = await polly.synthesizeSpeech({
TextType: 'ssml',
Text: text,
Text: ssml,
OutputFormat: 'mp3',
LanguageCode: voice.language,
VoiceId: voice.voice,

View File

@ -1,4 +1,4 @@
import { buildDict, buildList, capitalise } from './helpers.js';
import { buildDict, buildList, capitalise, escapePronunciationString } from './helpers.js';
import MORPHEMES from '../data/pronouns/morphemes.js';
const config = process.env.CONFIG || global.config;
@ -55,7 +55,7 @@ export class Example {
}).join(''));
}
pronounce(pronoun) {
toPronunciationString(pronoun) {
let interchangable = false;
const buildPronunciation = m => {
@ -68,29 +68,29 @@ export class Example {
return pronunciation
? (pronunciation.startsWith('=')
? pronunciation.substring(1)
: `<phoneme alphabet="ipa" ph="${pronunciation}">${morpheme}</phoneme>`
: `/${pronunciation}/`
)
: ( config.pronunciation.ipa && morpheme
? morpheme.split('').map(
c => [' ', ',', '.', ':', ';', '', '-'].includes(c)
? c
: `<phoneme alphabet="ipa" ph="${c}">${c}</phoneme>`
: `/${c}/`
).join('')
: morpheme
);
}
const ssml = '<speak>' + this.parts(pronoun).map(part => {
const pronunciationString = this.parts(pronoun).map(part => {
return part.variable
? buildPronunciation(part.str)
: part.str;
}).join('') + '</speak>';
: escapePronunciationString(part.str);
}).join('');
if (interchangable) {
return null;
}
return ssml;
return pronunciationString;
}
toString() {

View File

@ -250,6 +250,54 @@ const escapeChars = {
export const escapeHtml = (text) => text.replace(/[&<>"]/g, tag => escapeChars[tag] || tag);
export const escapePronunciationString = (text) => {
return text.replaceAll('\\', '\\\\')
.replaceAll('/', '\\/');
};
export const unescapePronunciationString = (pronunciationString) => {
return pronunciationString.replaceAll('\\/', '/')
.replaceAll('\\\\', '\\');
};
export const convertPronunciationStringToSsml = (pronunciationString) => {
const escapedString = escapeHtml(pronunciationString);
let ssml = '';
let escape = false;
let currentPhonemes = null;
for (const character of escapedString) {
if (escape) {
if (currentPhonemes === null) {
ssml += character;
} else {
currentPhonemes += character;
}
escape = false;
} else {
if (character === '\\') {
escape = true;
} else if (character == '/') {
if (currentPhonemes === null) {
currentPhonemes = '';
} else {
ssml += `<phoneme alphabet="ipa" ph="${currentPhonemes}"></phoneme>`;
currentPhonemes = null;
}
} else {
if (currentPhonemes === null) {
ssml += character;
} else {
currentPhonemes += character;
}
}
}
}
if (currentPhonemes !== null) {
ssml += `/${currentPhonemes}`;
}
return `<speak>${ssml}</speak>`;
};
export class ImmutableArray extends Array {
sorted(a, b) {
return new ImmutableArray(...[...this].sort(a, b));

57
test/helpers.test.js Normal file
View File

@ -0,0 +1,57 @@
import { describe, expect, test } from '@jest/globals';
import { convertPronunciationStringToSsml, escapePronunciationString } from '../src/helpers.js';
describe('when escaping pronunciation', () => {
test.each([
{
description: 'slashes get escaped',
text: 'w/o n/A',
pronunciationString: String.raw`w\/o n\/A`,
},
{
description: 'backslashes get escaped',
text: String.raw`\n is the symbol for a newline, \t for a tab`,
pronunciationString: String.raw`\\n is the symbol for a newline, \\t for a tab`,
}
])('$description', ({ text, pronunciationString }) => {
expect(escapePronunciationString(text)).toBe(pronunciationString);
});
});
describe('when converting pronunciation', () => {
test.each([
{
description: 'simple text is passed as-is',
pronunciationString: 'text',
ssml: '<speak>text</speak>',
},
{
description: 'slashes describe IPA phonemes',
pronunciationString: '/ðeɪ/',
ssml: '<speak><phoneme alphabet="ipa" ph="ðeɪ"></phoneme></speak>',
},
{
description: 'simple text and slashes can be combined',
pronunciationString: '/ðeɪ/ are',
ssml: '<speak><phoneme alphabet="ipa" ph="ðeɪ"></phoneme> are</speak>',
},
{
description: 'slashes can be escaped at front',
pronunciationString: String.raw`w\/o, n/A`,
ssml: '<speak>w/o, n/A</speak>',
},
{
description: 'slashes can be escaped at back',
pronunciationString: String.raw`w/o, n\/A`,
ssml: '<speak>w/o, n/A</speak>',
},
{
description: 'provided HTML is escaped',
pronunciationString: '<break time="1s"/>',
ssml: '<speak>&lt;break time=&quot;1s&quot;/&gt;</speak>',
},
])('$description', ({ pronunciationString, ssml }) => {
expect(convertPronunciationStringToSsml(pronunciationString)).toBe(ssml);
});
});