mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2025-01-10 15:02:06 -05:00
Merge pull request #2046 from ericrosenbaum/feature/tts-add-single-language-voices2
Add eight new languages to Text to Speech extension
This commit is contained in:
commit
7f69e0ce3f
2 changed files with 277 additions and 64 deletions
|
@ -65,6 +65,42 @@ const GIANT_ID = 'GIANT';
|
||||||
*/
|
*/
|
||||||
const KITTEN_ID = 'KITTEN';
|
const KITTEN_ID = 'KITTEN';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback rate for the tenor voice, for cases where we have only a female gender voice.
|
||||||
|
*/
|
||||||
|
const FEMALE_TENOR_RATE = 0.89; // -2 semitones
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback rate for the giant voice, for cases where we have only a female gender voice.
|
||||||
|
*/
|
||||||
|
const FEMALE_GIANT_RATE = 0.79; // -4 semitones
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language ids. The value for each language id is a valid Scratch locale.
|
||||||
|
*/
|
||||||
|
const CHINESE_ID = 'zh-cn';
|
||||||
|
const DANISH_ID = 'da';
|
||||||
|
const DUTCH_ID = 'nl';
|
||||||
|
const ENGLISH_ID = 'en';
|
||||||
|
const FRENCH_ID = 'fr';
|
||||||
|
const GERMAN_ID = 'de';
|
||||||
|
const HINDI_ID = 'hi';
|
||||||
|
const ICELANDIC_ID = 'is';
|
||||||
|
const ITALIAN_ID = 'it';
|
||||||
|
const JAPANESE_ID = 'ja';
|
||||||
|
const KOREAN_ID = 'ko';
|
||||||
|
const NORWEGIAN_ID = 'nb';
|
||||||
|
const POLISH_ID = 'pl';
|
||||||
|
const PORTUGUESE_BR_ID = 'pt-br';
|
||||||
|
const PORTUGUESE_ID = 'pt';
|
||||||
|
const ROMANIAN_ID = 'ro';
|
||||||
|
const RUSSIAN_ID = 'ru';
|
||||||
|
const SPANISH_ID = 'es';
|
||||||
|
const SPANISH_419_ID = 'es-419';
|
||||||
|
const SWEDISH_ID = 'sv';
|
||||||
|
const TURKISH_ID = 'tr';
|
||||||
|
const WELSH_ID = 'cy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for the text2speech blocks.
|
* Class for the text2speech blocks.
|
||||||
* @constructor
|
* @constructor
|
||||||
|
@ -92,6 +128,12 @@ class Scratch3Text2SpeechBlocks {
|
||||||
if (this.runtime) {
|
if (this.runtime) {
|
||||||
runtime.on('targetWasCreated', this._onTargetCreated);
|
runtime.on('targetWasCreated', this._onTargetCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all Scratch locales that are supported by the extension.
|
||||||
|
* @type {Array}
|
||||||
|
*/
|
||||||
|
this._supportedLocales = this._getSupportedLocales();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -148,55 +190,146 @@ class Scratch3Text2SpeechBlocks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An object with language names mapped to their language codes.
|
* An object with information for each language.
|
||||||
|
*
|
||||||
|
* A note on the different sets of locales referred to in this extension:
|
||||||
|
*
|
||||||
|
* SCRATCH LOCALE
|
||||||
|
* Set by the editor, and used to store the language state in the project.
|
||||||
|
* Listed in l10n: https://github.com/LLK/scratch-l10n/blob/master/src/supported-locales.js
|
||||||
|
* SUPPORTED LOCALE
|
||||||
|
* A Scratch locale that has a corresponding extension locale.
|
||||||
|
* EXTENSION LOCALE
|
||||||
|
* A locale corresponding to one of the available spoken languages
|
||||||
|
* in the extension. There can be multiple supported locales for a single
|
||||||
|
* extension locale. For example, for both written versions of chinese,
|
||||||
|
* zh-cn and zh-tw, we use a single spoken language (Mandarin). So there
|
||||||
|
* are two supported locales, with a single extension locale.
|
||||||
|
* SPEECH SYNTH LOCALE
|
||||||
|
* A different locale code system, used by our speech synthesis service.
|
||||||
|
* Each extension locale has a speech synth locale.
|
||||||
*/
|
*/
|
||||||
get LANGUAGE_INFO () {
|
get LANGUAGE_INFO () {
|
||||||
return {
|
return {
|
||||||
'Danish': 'da',
|
[CHINESE_ID]: {
|
||||||
'Dutch': 'nl',
|
name: 'Chinese (Mandarin)',
|
||||||
'English': 'en',
|
locales: ['zh-cn', 'zh-tw'],
|
||||||
'French': 'fr',
|
speechSynthLocale: 'cmn-CN',
|
||||||
'German': 'de',
|
singleGender: true
|
||||||
'Icelandic': 'is',
|
},
|
||||||
'Italian': 'it',
|
[DANISH_ID]: {
|
||||||
'Japanese': 'ja',
|
name: 'Danish',
|
||||||
'Polish': 'pl',
|
locales: ['da'],
|
||||||
'Portuguese (Brazilian)': 'pt-br',
|
speechSynthLocale: 'da-DK'
|
||||||
'Portuguese (European)': 'pt',
|
},
|
||||||
'Russian': 'ru',
|
[DUTCH_ID]: {
|
||||||
'Spanish (European)': 'es',
|
name: 'Dutch',
|
||||||
'Spanish (Latin American)': 'es-419'
|
locales: ['nl'],
|
||||||
};
|
speechSynthLocale: 'nl-NL'
|
||||||
|
},
|
||||||
|
[ENGLISH_ID]: {
|
||||||
|
name: 'English',
|
||||||
|
locales: ['en'],
|
||||||
|
speechSynthLocale: 'en-US'
|
||||||
|
},
|
||||||
|
[FRENCH_ID]: {
|
||||||
|
name: 'French',
|
||||||
|
locales: ['fr'],
|
||||||
|
speechSynthLocale: 'fr-FR'
|
||||||
|
},
|
||||||
|
[GERMAN_ID]: {
|
||||||
|
name: 'German',
|
||||||
|
locales: ['de'],
|
||||||
|
speechSynthLocale: 'de-DE'
|
||||||
|
},
|
||||||
|
[HINDI_ID]: {
|
||||||
|
name: 'Hindi',
|
||||||
|
locales: ['hi'],
|
||||||
|
speechSynthLocale: 'en-IN',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[ICELANDIC_ID]: {
|
||||||
|
name: 'Icelandic',
|
||||||
|
locales: ['is'],
|
||||||
|
speechSynthLocale: 'is-IS'
|
||||||
|
},
|
||||||
|
[ITALIAN_ID]: {
|
||||||
|
name: 'Italian',
|
||||||
|
locales: ['it'],
|
||||||
|
speechSynthLocale: 'it-IT'
|
||||||
|
},
|
||||||
|
[JAPANESE_ID]: {
|
||||||
|
name: 'Japanese',
|
||||||
|
locales: ['ja', 'ja-Hira'],
|
||||||
|
speechSynthLocale: 'ja-JP'
|
||||||
|
},
|
||||||
|
[KOREAN_ID]: {
|
||||||
|
name: 'Korean',
|
||||||
|
locales: ['ko'],
|
||||||
|
speechSynthLocale: 'ko-KR',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[NORWEGIAN_ID]: {
|
||||||
|
name: 'Norwegian',
|
||||||
|
locales: ['nb', 'nn'],
|
||||||
|
speechSynthLocale: 'nb-NO',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[POLISH_ID]: {
|
||||||
|
name: 'Polish',
|
||||||
|
locales: ['pl'],
|
||||||
|
speechSynthLocale: 'pl-PL'
|
||||||
|
},
|
||||||
|
[PORTUGUESE_BR_ID]: {
|
||||||
|
name: 'Portuguese (Brazilian)',
|
||||||
|
locales: ['pt-br'],
|
||||||
|
speechSynthLocale: 'pt-BR'
|
||||||
|
},
|
||||||
|
[PORTUGUESE_ID]: {
|
||||||
|
name: 'Portuguese (European)',
|
||||||
|
locales: ['pt'],
|
||||||
|
speechSynthLocale: 'pt-PT'
|
||||||
|
},
|
||||||
|
[ROMANIAN_ID]: {
|
||||||
|
name: 'Romanian',
|
||||||
|
locales: ['ro'],
|
||||||
|
speechSynthLocale: 'ro-RO',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[RUSSIAN_ID]: {
|
||||||
|
name: 'Russian',
|
||||||
|
locales: ['ru'],
|
||||||
|
speechSynthLocale: 'ru-RU'
|
||||||
|
},
|
||||||
|
[SPANISH_ID]: {
|
||||||
|
name: 'Spanish (European)',
|
||||||
|
locales: ['es'],
|
||||||
|
speechSynthLocale: 'es-ES'
|
||||||
|
},
|
||||||
|
[SPANISH_419_ID]: {
|
||||||
|
name: 'Spanish (Latin American)',
|
||||||
|
locales: ['es-419'],
|
||||||
|
speechSynthLocale: 'es-US'
|
||||||
|
},
|
||||||
|
[SWEDISH_ID]: {
|
||||||
|
name: 'Swedish',
|
||||||
|
locales: ['sv'],
|
||||||
|
speechSynthLocale: 'sv-SE',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[TURKISH_ID]: {
|
||||||
|
name: 'Turkish',
|
||||||
|
locales: ['tr'],
|
||||||
|
speechSynthLocale: 'tr-TR',
|
||||||
|
singleGender: true
|
||||||
|
},
|
||||||
|
[WELSH_ID]: {
|
||||||
|
name: 'Welsh',
|
||||||
|
locales: ['cy'],
|
||||||
|
speechSynthLocale: 'cy-GB',
|
||||||
|
singleGender: true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a temporary adapter to convert Scratch locale codes to Amazon polly's locale codes.
|
|
||||||
* @todo remove this once the speech synthesis server can perform this conversion
|
|
||||||
* @param {string} locale the Scratch locale to convert.
|
|
||||||
* @return {string} the Amazon polly locale.
|
|
||||||
*/
|
|
||||||
localeToPolly (locale) {
|
|
||||||
const pollyLocales = {
|
|
||||||
'da': 'da-DK', // Danish
|
|
||||||
'nl': 'nl-NL', // Dutch
|
|
||||||
'en': 'en-US', // English
|
|
||||||
'fr': 'fr-FR', // French
|
|
||||||
'de': 'de-DE', // German
|
|
||||||
'is': 'is-IS', // Icelandic
|
|
||||||
'it': 'it-IT', // Italian
|
|
||||||
'ja': 'ja-JP', // Japanese
|
|
||||||
'pl': 'pl-PL', // Polish
|
|
||||||
'pt-br': 'pt-BR', // Portuguese (Brazilian)
|
|
||||||
'pt': 'pt-PT', // Portuguese (European)
|
|
||||||
'ru': 'ru-RU', // Russian
|
|
||||||
'es': 'es-ES', // Spanish (European)
|
|
||||||
'es-419': 'es-US' // Spanish (Latin American)
|
|
||||||
};
|
};
|
||||||
let converted = 'en-US';
|
|
||||||
if (pollyLocales[locale]) {
|
|
||||||
converted = pollyLocales[locale];
|
|
||||||
}
|
|
||||||
return converted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -222,7 +355,7 @@ class Scratch3Text2SpeechBlocks {
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
get DEFAULT_LANGUAGE () {
|
get DEFAULT_LANGUAGE () {
|
||||||
return 'en';
|
return ENGLISH_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -334,7 +467,7 @@ class Scratch3Text2SpeechBlocks {
|
||||||
/**
|
/**
|
||||||
* Get the language code currently set in the editor, or fall back to the
|
* Get the language code currently set in the editor, or fall back to the
|
||||||
* browser locale.
|
* browser locale.
|
||||||
* @return {string} the language code.
|
* @return {string} a Scratch locale code.
|
||||||
*/
|
*/
|
||||||
getEditorLanguage () {
|
getEditorLanguage () {
|
||||||
return formatMessage.setup().locale ||
|
return formatMessage.setup().locale ||
|
||||||
|
@ -342,8 +475,8 @@ class Scratch3Text2SpeechBlocks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the language for speech synthesis.
|
* Get the language code currently set for the extension.
|
||||||
* @returns {string} the language code.
|
* @returns {string} a Scratch locale code.
|
||||||
*/
|
*/
|
||||||
getCurrentLanguage () {
|
getCurrentLanguage () {
|
||||||
const stage = this.runtime.getTargetForStage();
|
const stage = this.runtime.getTargetForStage();
|
||||||
|
@ -356,17 +489,18 @@ class Scratch3Text2SpeechBlocks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the language for speech synthesis.
|
* Set the language code for the extension.
|
||||||
* It is stored in the stage so it can be saved and loaded with the project.
|
* It is stored in the stage so it can be saved and loaded with the project.
|
||||||
* @param {string} languageCode a locale code to set.
|
* @param {string} locale a locale code.
|
||||||
*/
|
*/
|
||||||
setCurrentLanguage (languageCode) {
|
setCurrentLanguage (locale) {
|
||||||
const stage = this.runtime.getTargetForStage();
|
const stage = this.runtime.getTargetForStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
// Only set the language if it is in the list.
|
|
||||||
if (this.isSupportedLanguage(languageCode)) {
|
if (this.isSupportedLanguage(locale)) {
|
||||||
stage.textToSpeechLanguage = languageCode;
|
stage.textToSpeechLanguage = this._getExtensionLocaleForSupportedLocale(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the language is null, set it to the default language.
|
// If the language is null, set it to the default language.
|
||||||
// This can occur e.g. if the extension was loaded with the editor
|
// This can occur e.g. if the extension was loaded with the editor
|
||||||
// set to a language that is not in the list.
|
// set to a language that is not in the list.
|
||||||
|
@ -376,13 +510,49 @@ class Scratch3Text2SpeechBlocks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a language code is in the list of supported languages for the
|
* Get the extension locale for a supported locale, or null.
|
||||||
|
* @param {string} locale a locale code.
|
||||||
|
* @returns {?string} a locale supported by the extension.
|
||||||
|
*/
|
||||||
|
_getExtensionLocaleForSupportedLocale (locale) {
|
||||||
|
for (const lang in this.LANGUAGE_INFO) {
|
||||||
|
if (this.LANGUAGE_INFO[lang].locales.includes(locale)) {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.error(`cannot find extension locale for locale ${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the locale code used by the speech synthesis server corresponding to
|
||||||
|
* the current language code set for the extension.
|
||||||
|
* @returns {string} a speech synthesis locale.
|
||||||
|
*/
|
||||||
|
_getSpeechSynthLocale () {
|
||||||
|
let speechSynthLocale = this.LANGUAGE_INFO[this.DEFAULT_LANGUAGE].speechSynthLocale;
|
||||||
|
if (this.LANGUAGE_INFO[this.getCurrentLanguage()]) {
|
||||||
|
speechSynthLocale = this.LANGUAGE_INFO[this.getCurrentLanguage()].speechSynthLocale;
|
||||||
|
}
|
||||||
|
return speechSynthLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of the locales supported by this extension.
|
||||||
|
* @returns {Array} An array of locale strings.
|
||||||
|
*/
|
||||||
|
_getSupportedLocales () {
|
||||||
|
return Object.keys(this.LANGUAGE_INFO).reduce((acc, lang) =>
|
||||||
|
acc.concat(this.LANGUAGE_INFO[lang].locales), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Scratch language code is in the list of supported languages for the
|
||||||
* speech synthesis service.
|
* speech synthesis service.
|
||||||
* @param {string} languageCode the language code to check.
|
* @param {string} languageCode the language code to check.
|
||||||
* @returns {boolean} true if the language code is supported.
|
* @returns {boolean} true if the language code is supported.
|
||||||
*/
|
*/
|
||||||
isSupportedLanguage (languageCode) {
|
isSupportedLanguage (languageCode) {
|
||||||
return Object.values(this.LANGUAGE_INFO).includes(languageCode);
|
return this._supportedLocales.includes(languageCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -401,9 +571,9 @@ class Scratch3Text2SpeechBlocks {
|
||||||
* @return {array} the text and value for each menu item.
|
* @return {array} the text and value for each menu item.
|
||||||
*/
|
*/
|
||||||
getLanguageMenu () {
|
getLanguageMenu () {
|
||||||
return Object.keys(this.LANGUAGE_INFO).map(languageName => ({
|
return Object.keys(this.LANGUAGE_INFO).map(key => ({
|
||||||
text: languageName,
|
text: this.LANGUAGE_INFO[key].name,
|
||||||
value: this.LANGUAGE_INFO[languageName]
|
value: key
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -457,16 +627,29 @@ class Scratch3Text2SpeechBlocks {
|
||||||
speakAndWait (args, util) {
|
speakAndWait (args, util) {
|
||||||
// Cast input to string
|
// Cast input to string
|
||||||
let words = Cast.toString(args.WORDS);
|
let words = Cast.toString(args.WORDS);
|
||||||
let locale = this.localeToPolly(this.getCurrentLanguage());
|
let locale = this._getSpeechSynthLocale();
|
||||||
|
|
||||||
const state = this._getState(util.target);
|
const state = this._getState(util.target);
|
||||||
|
|
||||||
const gender = this.VOICE_INFO[state.voiceId].gender;
|
let gender = this.VOICE_INFO[state.voiceId].gender;
|
||||||
const playbackRate = this.VOICE_INFO[state.voiceId].playbackRate;
|
let playbackRate = this.VOICE_INFO[state.voiceId].playbackRate;
|
||||||
|
|
||||||
|
// Special case for voices where the synthesis service only provides a
|
||||||
|
// single gender voice. In that case, always request the female voice,
|
||||||
|
// and set special playback rates for the tenor and giant voices.
|
||||||
|
if (this.LANGUAGE_INFO[this.getCurrentLanguage()].singleGender) {
|
||||||
|
gender = 'female';
|
||||||
|
if (state.voiceId === TENOR_ID) {
|
||||||
|
playbackRate = FEMALE_TENOR_RATE;
|
||||||
|
}
|
||||||
|
if (state.voiceId === GIANT_ID) {
|
||||||
|
playbackRate = FEMALE_GIANT_RATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.voiceId === KITTEN_ID) {
|
if (state.voiceId === KITTEN_ID) {
|
||||||
words = words.replace(/\S+/g, 'meow');
|
words = words.replace(/\S+/g, 'meow');
|
||||||
locale = 'en-US';
|
locale = this.LANGUAGE_INFO[this.DEFAULT_LANGUAGE].speechSynthLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build up URL
|
// Build up URL
|
||||||
|
|
30
test/unit/extension_text_to_speech.js
Normal file
30
test/unit/extension_text_to_speech.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
const test = require('tap').test;
|
||||||
|
const TextToSpeech = require('../../src/extensions/scratch3_text2speech/index.js');
|
||||||
|
|
||||||
|
const fakeStage = {
|
||||||
|
textToSpeechLanguage: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeRuntime = {
|
||||||
|
getTargetForStage: () => fakeStage,
|
||||||
|
on: () => {} // Stub out listener methods used in constructor.
|
||||||
|
};
|
||||||
|
|
||||||
|
const ext = new TextToSpeech(fakeRuntime);
|
||||||
|
|
||||||
|
test('if no language is saved in the project, use default', t => {
|
||||||
|
t.strictEqual(ext.getCurrentLanguage(), 'en');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if an unsupported language is dropped onto the set language block, use default', t => {
|
||||||
|
ext.setLanguage({LANGUAGE: 'nope'});
|
||||||
|
t.strictEqual(ext.getCurrentLanguage(), 'en');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get the extension locale for a supported locale that differs', t => {
|
||||||
|
ext.setLanguage({LANGUAGE: 'ja-Hira'});
|
||||||
|
t.strictEqual(ext.getCurrentLanguage(), 'ja');
|
||||||
|
t.end();
|
||||||
|
});
|
Loading…
Reference in a new issue