UNPKG

@met4citizen/headtts

Version:

HeadTTS: A free Javascript text-to-speech with timestamps and visemes.

543 lines (471 loc) 16.6 kB
import { LanguageBase } from "./language.mjs" import * as utils from "./utils.mjs"; /** * @class Finnish language module * @author Mika Suominen */ class Language extends LanguageBase { /** * @constructor */ constructor( settings = null ) { super(settings); // Add finnish letters with diaritics (upper case) // NOTE: Diacritics will be removed unless added to this object. this.normalizedLettersUpper = { 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', 'ß': 'SS', 'Ø': 'O', 'Æ': 'AE', 'Œ': 'OE', 'Ð': 'D', 'Þ': 'TH', 'Ł': 'L', "Ä": "Ä", "Ö": "Ö", "Å": "Å" }; // Finnish vowels this.fiVowels = { 'A': 'A', 'E': 'E', 'I': 'I', 'O': 'O', 'U': 'U', 'Y': 'Y', "Ä": "Ä", "Ö": "Ö", "Å": "Å" }; // Finnish consonants this.fiConsonants = { 'B': 'B', 'C': 'C', 'D': 'D', 'F': 'F', 'G': 'G', 'H': 'H', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'V': 'V', 'W': 'W', 'X': 'X', 'Z': 'Z' }; // Diphthongs in the first syllable that we do not hyphenate this.fiDiphthongFirst = { "AI": "AI", "EI": "EI", "OI": "OI", "YI": "YI", "ÄI": "ÄI", "ÖI": "ÖI", "EY": "EY", "IY": "IY", "ÄY": "ÄY", "ÖY": "ÖY", "AU": "AU", "EU": "EU", "IU": "IU", "OU": "OU", "IE": "IE", "UO": "UO", "YÖ": "YÖ" }; // Diphthongs after the first syllable that we do not hyphenate this.fiDiphthongEnd = { "AI": "AI", "EI": "EI", "OI": "OI", "YI": "YI", "ÄI": "ÄI", "ÖI": "ÖI", "EY": "EY", "IY": "IY", "ÄY": "ÄY", "ÖY": "ÖY", "AU": "AU", "EU": "EU", "IU": "IU", "OU": "OU", "UO": "UO", "YÖ": "YÖ" }; // Pronounciation rules for Finnish this.rules = { 'A': [ "AUSTR[A]=ɑ ː", "A[A]=ː", "[A]=ɑ" ], 'B': [ "B[B]=ː", "[B]=b" ], 'C': [ "[CITY]=s i t i", "C[C]=ː", "[C]I=s", "[C]E=s", "[C]Y=s", "[C]=k" ], 'D': [ "D[D]=ː", "[D]=d" ], 'E': [ "E[E]=ː", "[E]=e" ], 'F': [ "[F]=f" ], 'G': [ "G[G]=ː", "[G]=ɡ" ], 'H': [ "[H]=h" ], 'I': [ "I[I]=ː", "[I]=i" ], 'J': [ "[J]=j" ], 'K': [ "#LLE[K]#=kː", "#NNE[K]#=kː", "K[K]=ː", "[K]=k" ], 'L': [ "[L]=l" ], 'M': [ "SYDÄ[M]=m ː", "[M]=m" ], 'N': [ "[NG]=ŋ ː", "[N]P=m", "[N]=n" ], 'O': [ "SYMB[O]LOI=o", "SYMB[O]L=o ː", "O[O]=ː", "[O]=o" ], 'P': [ " SHAM[P]O=p ː", " KAM[P]ANJ=p ː", " OTA[P]#=p ː", " OLE[P]#=p ː", " TULE[P]#=p ː", "P[P]=ː", "[P]=p" ], 'Q': [ "Q[Q]=ː", "[Q]=q" ], 'R': [ "[RUO]AN=r u ː", "[R]=r" ], 'S': [ "[S]=s" ], 'T': [ "T[T]=ː", "[T]=t" ], 'U': [ "U[U]=ː", "[U]=u" ], 'V': [ "[V]=ʋ" ], 'W': [ "[W]=ʋ" ], 'X': [ "X[X]=ː s", "[X]=k s" ], 'Y': [ "Y[Y]=ː", "[Y]=y" ], 'Z': [ "[Z]=t s" ], 'Å': [ "Å[Å]=ː", "[Å]=o" ], 'Ä': [ "Ä[Ä]=ː", "[Ä]=æ" ], 'Ö': [ "Ö[Ö]=ː", "[Ö]=ø" ] }; const ops = { '#': '[AEIOUYÄÖÅ]+', // One or more vowels AEIOUYÄÖÅ ' ': '\\b' // Start/end of the word }; const IPAToMisaki = { "ɚ": ["ɜ","ɹ"], "ˈɝ": ["ˈɜ","ɹ"], "ˌɝ": ["ˌɜ","ɹ"], "tʃ": ["ʧ"], "dʒ": ["ʤ"], "eɪ": ["A"], "ˈeɪ": ["ˈA"], "ˌeɪ": ["ˌA"], "aɪ": ["I"], "ˈaɪ": ["ˈI"], "ˌaɪ": ["ˌI"], "aʊ": ["W"], "ˈaʊ": ["ˈW"], "ˌaʊ": ["ˌW"], "ɔɪ": ["Y"], "ˈɔɪ": ["ˈY"], "ˌɔɪ": ["ˌY"], "oʊ": ["O"], "ˈoʊ": ["ˈO"], "ˌoʊ": ["ˌO"], "əʊ": ["Q"], "əʊ": ["ˈQ"], "əʊ": ["ˌQ"] }; // Convert rules to regex Object.keys(this.rules).forEach( key => { this.rules[key] = this.rules[key].map( rule => { const posL = rule.indexOf('['); const posR = rule.indexOf(']'); const posE = rule.indexOf('='); const strLeft = rule.substring(0,posL); const strLetters = rule.substring(posL+1,posR); const strRight = rule.substring(posR+1,posE); const strPhonemes = rule.substring(posE+1); const o = { regex: '', move: 0, phonemes: [] }; let exp = ''; exp += [...strLeft].map( x => ops[x] || x ).join(''); const ctxLetters = [...strLetters]; ctxLetters[0] = ctxLetters[0].toLowerCase(); exp += ctxLetters.join(''); o.move = ctxLetters.length; exp += [...strRight].map( x => ops[x] || x ).join(''); o.regex = new RegExp(exp); if ( strPhonemes.length ) { strPhonemes.split(' ').forEach( ph => { if ( IPAToMisaki.hasOwnProperty(ph) ) { o.phonemes.push( ...IPAToMisaki[ph] ); } else { o.phonemes.push( ph ); } }); } return o; }); }); // Characters to Finnish words this.charactersToWords = { '!': "HUUTOMERKKI", '"': "LAINAUSMERKKI", '#': "RISUAITA", '%': "PROSENTTI", '&': "ET-MERKKI", "'": "HIPSUKKA", '(': "SULKU AUKI", ')': "SULKU KIINNI", '+': "PLUS", '-': 'VÄLIVIIVA', '—': 'VÄLIVIIVA', ',': "PILKKU", '.': "PISTE", '/': "KAUTTAVIIVA", ':': "KAKSOISPISTE", ';': "PUOLIPISTE", '?': "KYSYMYSMERKKI", 'A': "AA", 'B': "BEE", 'C': "CEE", 'D': "DEE", 'E': "EE", 'F': "ÄF", 'G': "GEE", 'H': "HOO", 'I': "II", 'J': "JII", 'K': "KOO", 'L': "ÄL", 'M': "ÄM", 'N': "ÄN", 'O': "OO", 'P': "PEE", 'Q': "KUU", 'R': "ÄR", 'S': "ÄS", 'T': "TEE", 'U': "UU", 'V': "VEE", 'W': "KAKSOISVEE", 'X': "ÄKS", 'Y': "YY", 'Z': "TSET", '1': "YKSI", '2': "KAKSI", '3': "KOLME", '4': "NELJÄ", '5': "VIISI", '6': "KUUSI", '7': "SEITSEMÄN", '8': "KAHDEKSAN", '9': "YHDEKSÄN", '0': "NOLLAO", '{': "AALTOSULKU AUKI", '}': "AALTOSULKU KIINNI", '$': "DOLLARI", '€': "EURO" }; // Finnish number words this.numbers = [ 'NOLLA', 'YKSI', 'KAKSI', 'KOLME', 'NELJÄ', 'VIISI', 'KUUSI', 'SEITSEMÄN', 'KAHDEKSAN', 'YHDEKSÄN', "KYMMENEN", "YKSITOISTA", "KAKSITOISTA", "KOLMETOISTA", "NELJÄTOISTA", "VIISITOISTA", "KUUSITOISTA", 'SEITSEMÄNTOISTA', 'KAHDEKSANTOISTA', 'YHDEKSÄNTOISTA' ]; // Date & Time this.days = [ "", "ENSIMMÄINEN", "TOINEN", "KOLMAS", "NELJÄS", "VIIDES", "KUUDES", "SEITSEMÄS", "KAHDEKSAS", "YHDEKSÄS", "KYMMENES", "YHDESTOISTA", "KAHDESTOISTA", "KOLMASTOISTA", "NELJÄSTOISTA", "VIIDESTOISTA", "KUUDESTOISTA", "SEITSEMÄSTOISTA", "KAHDEKSASTOISTA", "YHDEKSÄSTOISTA", "KAHDESKYMMENES", "KAHDESKYMMENES-ENSIMMÄINEN", "KAHDESKYMMENES-TOINEN", "KAHDESKYMMENES-KOLMAS", "KAHDESKYMMENES-NELJÄS", "KAHDESKYMMENES-VIIDES", "KAHDESKYMMENES-KUUDES", "KAHDESKYMMENES-SEITSEMÄS", "KAHDESKYMMENES-KAHDEKSAS", "KAHDESKYMMENES-YHDEKSÄS", "KOLMASKYMMENES", "KOLMASKYMMENES-ENSIMMÄINEN" ]; this.months = [ "", "TAMMIKUUTA", "HELMIKUUTA", "MAALISKUUTA", "HUHTIKUUTA", "TOUKOKUUTA", "KESÄKUUTA", "HEINÄKUUTA", "ELOKUUTA", "SYYSKUUTA", "LOKAKUUTA", "MARRASKUUTA", "JOULUKUUTA" ]; // Symbols to Finnish // TODO: Implement these this.symbols = { '%': 'PROSENTTIA', '€': 'EUROA', '&': 'JA', '+': 'PLUS', '$': 'DOLLARIA' }; this.symbolsReg = /[%€&\+\$]/g; if ( this.settings.trace ) { utils.trace( 'Language module "fi" initiated.' ); } } /** * Add one dictionary line. For Finnish, it is a dictionary * of compound words. * NOTE: We only add three characters for the last part as * we need only partial match. * * @param {string} s Line */ addToDictionary(s) { if ( s.startsWith(";;;") ) return; // Ignore comment line const parts = s.split("\t"); const len = parts.length; if ( len >= 2 ) { const first = parts[0]; if ( !this.dictionary.hasOwnProperty(parts[0]) ) { this.dictionary[first] = {}; } for( let i=1; i<len; i++ ) { const second = parts[i]; this.dictionary[first][second] = null; } } } /** * Convert number to words. * * @param {number|string} num Number * @return {string} String */ convertNumberToWords(num) { const w = []; let n = parseFloat(num); if ( n === undefined ) return num; let p = (n,z,w0,w1,w2) => { if ( n < z ) return n; const d = Math.floor(n/z); w.push( w0 + ((d === 1) ? w1 : this.convertNumberToWords(d.toString()) + w2) ); return n - d * z; } if ( n < 0 ) { w.push('MIINUS '); n = Math.abs(n); } n = p(n,1000000000,' ','MILJARDI',' MILJARDIA'); n = p(n,1000000,' ','MILJOONA',' MILJOONAA'); n = p(n,1000,'', 'TUHAT','TUHATTA'); n = p(n,100,'','SATA','SATAA'); if ( n > 20 ) n = p(n,10,'','','KYMMENTÄ'); if ( n >= 1) { let d = Math.floor(n); w.push( this.numbers[d] ); n -= d; } if ( n >= 0 && Math.abs(parseFloat(num)) < 1) w.push( 'NOLLA' ); if ( n > 0 ) { let d = num.split('.'); if ( d.length > 1 ) { w.push( ' PILKKU' ); let c = [...d[d.length-1]]; for( let i=0; i<c.length; i++ ) { w.push( ' ' + this.numbers[c[i]] ); } } } return w.join('').trim(); } /** * Set the `text` to be spoken by analysing the part content. * * @param {Object} part Current part * @param {number} i Index * @param {Object[]} arr All the parts. */ partSetText(part,i,arr) { // Call super to pre-populate super.partSetText(part,i,arr); // Language specific implementation switch( part.type ) { case "text": // Check if this is actually a number const s = part.text; if ( s ) { const num = s.replace(/,/g, '').trim(); if ( !isNaN(num) && !isNaN(parseFloat(num)) ) { part.text = this.convertNumberToWords(num) + " "; } } break; case "characters": const t = []; const chars = [...part.value.toUpperCase()]; const len = chars.length; for( let i=0; i<len; i++ ) { const c = chars[i]; if ( this.charactersToWords.hasOwnProperty(c) ) { t.push( this.charactersToWords[c] ); } else { t.push(""); // Generates a space for unknown characters } } part.text = t.join(" ") + " "; break; case "number": part.text = this.convertNumberToWords(part.value) + " "; break; case "date": const date = new Date(part.value); const month = this.months[date.getMonth()]; const day = this.days[date.getDate()]; const year = this.convertNumberToWords(date.getFullYear()); part.text = day + " " + month + " " + year + " "; break; case "time": const time = new Date(part.value); let hours = time.getHours(); // 0–23 const minutes = time.getMinutes(); // 0–59 part.text = this.convertNumberToWords(hours) + " "; part.text += this.convertNumberToWords(minutes) + " "; break; } } /** * Split compound word into its parts. * * @param {string} word Normalized word in upper case * @return {string[]} Parts of the word */ splitOnCompoundWords(s) { // Split for hyphens const hyphens = s.split("-").filter( x => x.length ); const len = hyphens.length; if ( len > 1 ) { // Check if parts are compound words return hyphens.map( (x,i) => { const isLast = i === (len-1); return this.splitOnCompoundWords( x + (isLast ? "" : "-") ); }).flat(); } else { // Check compound word dictionary for( let i=s.length; i>2; i-- ) { const partFirst = s.substring(0,i); if ( this.dictionary.hasOwnProperty(partFirst) ) { const item = this.dictionary[partFirst]; const partNext4 = s.substring(i,i+4); const partNext3 = s.substring(i,i+3); if ( item.hasOwnProperty(partNext4) || item.hasOwnProperty(partNext3) ) { return [ partFirst, ...this.splitOnCompoundWords(s.substring(i)) ]; } } } return [s]; } } /** * Split single word into syllables. * NOTE: Doesn't handle compound words, so you slip on * compound words before calling this method. * * @param {string} s Normalized and upper case word * @return {string[]} Syllables. */ splitOnSyllables(s) { const syllables = []; const chars = [...s]; let len = chars.length; let i = 0; let syllable = ""; let isVowelFound = false; let isConsonantFound = false; while( i < len ) { const isLast = i === (len-1); const c = chars[i]; const isVowel = this.fiVowels.hasOwnProperty(c); const isConsonant = this.fiConsonants.hasOwnProperty(c); isVowelFound ||= isVowel; isConsonantFound ||= isConsonant; const isNotLetter = !isVowel && !isConsonant; const cSecond = isLast ? null : chars[i+1]; const isSecondVowel = this.fiVowels.hasOwnProperty(cSecond); const cTwo = isLast ? null : (c + cSecond); const isDiphthong = isVowel && isSecondVowel && (c !== cSecond) && (syllables.length === 0 ? !this.fiDiphthongFirst.hasOwnProperty(cTwo) : !this.fiDiphthongEnd.hasOwnProperty(cTwo)); const isBreakBefore = isVowelFound && isConsonant && isSecondVowel; const isBreakAfter = isLast || isDiphthong || isNotLetter; // Break before current letter if ( isBreakBefore ) { syllables.push( syllable ); syllable = ""; } syllable += c; // Break after the current letter if ( isBreakAfter ) { syllables.push( syllable ); syllable = ""; } i++; } return syllables; } /** * Split word or compound word on stress. * * @param {string} s Normalized and upper case word or compound word * @return {string[]} Stresses parts. */ splitOnStress(s) { const stressed = []; const words = this.splitOnCompoundWords(s); const lenWords = words.length; for( let i=0; i<lenWords; i++ ) { const word = words[i]; const syllables = this.splitOnSyllables(word); const lenSyllables = syllables.length; let stress = ""; for( let j=0; j<lenSyllables; j++ ) { const syllable = syllables[j]; const isLast = j === (lenSyllables-1); if ( j > 0 && (j-1) % 2 && !isLast ) { stressed.push( stress ); stress = ""; } stress += syllable; if ( isLast ) { stressed.push( stress ); break; } } } return stressed; } /** * Convert graphemes to phonemes. * * @param {string} s Word, normalized and in upper case * @return {string[]} Array of phonemes */ phonemizeWord(s) { let phonemes = []; // Split on stress const parts = this.splitOnStress(s); parts.forEach( (x,i) => { // Stress if ( x ) { if ( i === 0 ) { phonemes.push("ˈ"); } else { phonemes.push("ˌ"); } } // Phonemize const chars = [...x]; let len = chars.length; let j = 0; while( j < len ) { const c = chars[j]; if ( this.punctuations.hasOwnProperty(c) ) { phonemes.push( this.punctuations[c] ); j++; } else { const ruleset = this.rules[c]; if ( ruleset ) { const lenRuleset = ruleset.length; for(let k=0; k<lenRuleset; k++) { const rule = ruleset[k]; const test = x.substring(0, j) + c.toLowerCase() + x.substring(j+1); let matches = test.match(rule.regex); if ( matches ) { phonemes.push( ...rule.phonemes ); j += rule.move; break; } } } else { j++; } } } }); if ( this.settings.trace ) { utils.trace( 'Rules: "' + s + '" => "' + phonemes.join("") + '"' ); } return phonemes; } /** * Post process the parts to be spoken. * * @param {Object[]} parts Parts */ postProcessSpeak(parts) { parts.forEach( x => { }); return parts; } } export { Language };