const formatMessage = require('format-message'); const languageNames = require('scratch-translate-extension-languages'); const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const Cast = require('../../util/cast'); const MathUtil = require('../../util/math-util'); const Clone = require('../../util/clone'); const log = require('../../util/log'); const {fetchWithTimeout} = require('../../util/fetch-with-timeout'); /** * Icon svg to be displayed in the blocks category menu, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const menuIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUyLjIgKDY3MTQ1KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5FeHRlbnNpb25zL1NvZnR3YXJlL1RleHQtdG8tU3BlZWNoLU1lbnU8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZyBpZD0iRXh0ZW5zaW9ucy9Tb2Z0d2FyZS9UZXh0LXRvLVNwZWVjaC1NZW51IiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8ZyBpZD0idGV4dDJzcGVlY2giIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIuMDAwMDAwLCAyLjAwMDAwMCkiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik01Ljc1LDguODM0NjcxNzMgQzUuNzUsOC4zMjY5NjM0NCA1LjAwMzAwNzI3LDguMDQyMjEzNzEgNC41NTYyODAxMiw4LjQ0NDE0OTk5IEwzLjIwNjI4MDEyLDkuNTI1MzU3MDIgQzIuNjk2NzMzNzgsOS45MzM0NDk2OCAyLjAzNzQ4Njc1LDEwLjE2NTg3ODggMS4zNSwxMC4xNjU4Nzg4IEwxLjE1LDEwLjE2NTg3ODggQzAuNjMyNTk2MTY1LDEwLjE2NTg3ODggMC4yNSwxMC41MTA2MDAyIDAuMjUsMTAuOTUyMDM1NSBMMC4yNSwxMy4wNjkzOTkzIEMwLjI1LDEzLjUxMDgzNDYgMC42MzI1OTYxNjUsMTMuODU1NTU2IDEuMTUsMTMuODU1NTU2IEwxLjM1LDEzLjg1NTU1NiBDMi4wNzg3Nzg0MSwxMy44NTU1NTYgMi43MjY4NjE2MSwxNC4wNjY3NjM2IDMuMjU5ODYwNDksMTQuNDk5IEw0LjU1OTIwMTQ3LDE1LjU3OTY2MDggQzUuMDEzMDkyNzYsMTUuOTU0NTM5NiA1Ljc1LDE1LjY3MzYzNDQgNS43NSwxNS4xNDE3MTI4IEw1Ljc1LDguODM0NjcxNzMgWiIgaWQ9InNwZWFrZXIiIHN0cm9rZS1vcGFjaXR5PSIwLjE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMC41IiBmaWxsPSIjNEQ0RDREIj48L3BhdGg+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMC43MDQ4MzEzLDggQzkuNzkwNjc0NjgsOS4xMzExNDg0NyA4LjMwNjYxODQsOS43MTQyODU3MSA3LjgzMzMzMzMzLDkuNzE0Mjg1NzEgQzcuODMzMzMzMzMsOS43MTQyODU3MSA3LjUsOS43MTQyODU3MSA3LjUsOS4zODA5NTIzOCBDNy41LDkuMDg1MjI2ODQgOC4wNjIyMDE2OCw4LjkwMTk0MTY0IDguMTg5MDYwNjcsNy41Njc1NDA1OCBDNi44ODk5Njk5MSw2LjkwNjc5MDA1IDYsNS41NTczMjY4MyA2LDQgQzYsMS43OTA4NjEgNy43OTA4NjEsNC4wNTgxMjI1MWUtMTYgMTAsMCBMMTIsMCBDMTQuMjA5MTM5LC00LjA1ODEyMjUxZS0xNiAxNiwxLjc5MDg2MSAxNiw0IEMxNiw2LjIwOTEzOSAxNC4yMDkxMzksOCAxMiw4IEwxMC43MDQ4MzEzLDggWiIgaWQ9InNwZWVjaCIgZmlsbD0iIzBFQkQ4QyI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+'; /** * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. * @type {string} */ // eslint-disable-next-line max-len const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNDBweCIgaGVpZ2h0PSI0MHB4IiB2aWV3Qm94PSIwIDAgNDAgNDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUyLjIgKDY3MTQ1KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5FeHRlbnNpb25zL1NvZnR3YXJlL1RleHQtdG8tU3BlZWNoLUJsb2NrPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9IkV4dGVuc2lvbnMvU29mdHdhcmUvVGV4dC10by1TcGVlY2gtQmxvY2siIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1vcGFjaXR5PSIwLjE1Ij4KICAgICAgICA8ZyBpZD0idGV4dDJzcGVlY2giIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQuMDAwMDAwLCA0LjAwMDAwMCkiIGZpbGwtcnVsZT0ibm9uemVybyIgc3Ryb2tlPSIjMDAwMDAwIj4KICAgICAgICAgICAgPHBhdGggZD0iTTExLjUsMTcuNjY5MzQzNSBDMTEuNSwxNi42NTM5MjY5IDEwLjAwNjAxNDUsMTYuMDg0NDI3NCA5LjExMjU2MDI0LDE2Ljg4ODMgTDYuNDEyNTYwMjQsMTkuMDUwNzE0IEM1LjM5MzQ2NzU1LDE5Ljg2Njg5OTQgNC4wNzQ5NzM1MSwyMC4zMzE3NTc1IDIuNywyMC4zMzE3NTc1IEwyLjMsMjAuMzMxNzU3NSBDMS4yNjUxOTIzMywyMC4zMzE3NTc1IDAuNSwyMS4wMjEyMDAzIDAuNSwyMS45MDQwNzEgTDAuNSwyNi4xMzg3OTg2IEMwLjUsMjcuMDIxNjY5MyAxLjI2NTE5MjMzLDI3LjcxMTExMiAyLjMsMjcuNzExMTEyIEwyLjcsMjcuNzExMTEyIEM0LjE1NzU1NjgyLDI3LjcxMTExMiA1LjQ1MzcyMzIyLDI4LjEzMzUyNzEgNi41MTk3MjA5OCwyOC45OTggTDkuMTE4NDAyOTMsMzEuMTU5MzIxNiBDMTAuMDI2MTg1NSwzMS45MDkwNzkzIDExLjUsMzEuMzQ3MjY4OSAxMS41LDMwLjI4MzQyNTUgTDExLjUsMTcuNjY5MzQzNSBaIiBpZD0ic3BlYWtlciIgZmlsbD0iIzRENEQ0RCI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMjEuNjQzNjA2NiwxNi41IEMxOS45NzcwMDk5LDE4LjQzNzAyMzQgMTcuMTA1MDI3NSwxOS45Mjg1NzE0IDE1LjY2NjY2NjcsMTkuOTI4NTcxNCBDMTUuNTEyNjM5NywxOS45Mjg1NzE0IDE1LjMxNjYyOTIsMTkuODk1OTAzIDE1LjEwOTcyNjUsMTkuNzkyNDUxNyBDMTQuNzM3NjAzOSwxOS42MDYzOTA0IDE0LjUsMTkuMjQ5OTg0NiAxNC41LDE4Ljc2MTkwNDggQzE0LjUsMTguNjU2ODA0MSAxNC41MTcwNTU1LDE4LjU1NDUwNzYgMTQuNTQ5NDQ2NywxOC40NTQwODQ0IEMxNC42MjU3NTQ1LDE4LjIxNzUwNjMgMTUuMTczNTcyMSwxNy40Njc1MzEgMTUuMjc3MjA3MSwxNy4yODA5ODgxIEMxNS41NDYzNTI2LDE2Ljc5NjUyNjEgMTUuNzM5MDI1LDE2LjIwNjM1NjEgMTUuODQzMjg5MSwxNS40MTYwMDM0IEMxMy4xODk3MDA1LDEzLjkyNjgzNjkgMTEuNSwxMS4xMTM5NjY4IDExLjUsOCBDMTEuNSwzLjMwNTU3OTYzIDE1LjMwNTU3OTYsLTAuNSAyMCwtMC41IEwyNCwtMC41IEMyOC42OTQ0MjA0LC0wLjUgMzIuNSwzLjMwNTU3OTYzIDMyLjUsOCBDMzIuNSwxMi42OTQ0MjA0IDI4LjY5NDQyMDQsMTYuNSAyNCwxNi41IEwyMS42NDM2MDY2LDE2LjUgWiIgaWQ9InNwZWVjaCIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+'; /** * The url of the synthesis server. * @type {string} */ const SERVER_HOST = 'https://synthesis-service.scratch.mit.edu'; /** * How long to wait in ms before timing out requests to synthesis server. * @type {int} */ const SERVER_TIMEOUT = 10000; // 10 seconds /** * Volume for playback of speech sounds, as a percentage. * @type {number} */ const SPEECH_VOLUME = 250; /** * An id for one of the voices. */ const ALTO_ID = 'ALTO'; /** * An id for one of the voices. */ const TENOR_ID = 'TENOR'; /** * An id for one of the voices. */ const SQUEAK_ID = 'SQUEAK'; /** * An id for one of the voices. */ const GIANT_ID = 'GIANT'; /** * An id for one of the voices. */ 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 ARABIC_ID = 'ar'; 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. * @constructor */ class Scratch3Text2SpeechBlocks { constructor (runtime) { /** * The runtime instantiating this block package. * @type {Runtime} */ this.runtime = runtime; /** * Map of soundPlayers by sound id. * @type {Map<string, SoundPlayer>} */ this._soundPlayers = new Map(); this._stopAllSpeech = this._stopAllSpeech.bind(this); if (this.runtime) { this.runtime.on('PROJECT_STOP_ALL', this._stopAllSpeech); } this._onTargetCreated = this._onTargetCreated.bind(this); if (this.runtime) { runtime.on('targetWasCreated', this._onTargetCreated); } /** * A list of all Scratch locales that are supported by the extension. * @type {Array} */ this._supportedLocales = this._getSupportedLocales(); } /** * An object with info for each voice. */ get VOICE_INFO () { return { [ALTO_ID]: { name: formatMessage({ id: 'text2speech.alto', default: 'alto', description: 'Name for a voice with ambiguous gender.' }), gender: 'female', playbackRate: 1 }, [TENOR_ID]: { name: formatMessage({ id: 'text2speech.tenor', default: 'tenor', description: 'Name for a voice with ambiguous gender.' }), gender: 'male', playbackRate: 1 }, [SQUEAK_ID]: { name: formatMessage({ id: 'text2speech.squeak', default: 'squeak', description: 'Name for a funny voice with a high pitch.' }), gender: 'female', playbackRate: 1.19 // +3 semitones }, [GIANT_ID]: { name: formatMessage({ id: 'text2speech.giant', default: 'giant', description: 'Name for a funny voice with a low pitch.' }), gender: 'male', playbackRate: 0.84 // -3 semitones }, [KITTEN_ID]: { name: formatMessage({ id: 'text2speech.kitten', default: 'kitten', description: 'A baby cat.' }), gender: 'female', playbackRate: 1.41 // +6 semitones } }; } /** * 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 () { return { [ARABIC_ID]: { name: 'Arabic', locales: ['ar'], speechSynthLocale: 'arb', singleGender: true }, [CHINESE_ID]: { name: 'Chinese (Mandarin)', locales: ['zh-cn', 'zh-tw'], speechSynthLocale: 'cmn-CN', singleGender: true }, [DANISH_ID]: { name: 'Danish', locales: ['da'], speechSynthLocale: 'da-DK' }, [DUTCH_ID]: { name: 'Dutch', 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: 'hi-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 } }; } /** * The key to load & store a target's text2speech state. * @return {string} The key. */ static get STATE_KEY () { return 'Scratch.text2speech'; } /** * The default state, to be used when a target has no existing state. * @type {Text2SpeechState} */ static get DEFAULT_TEXT2SPEECH_STATE () { return { voiceId: ALTO_ID }; } /** * A default language to use for speech synthesis. * @type {string} */ get DEFAULT_LANGUAGE () { return ENGLISH_ID; } /** * @param {Target} target - collect state for this target. * @returns {Text2SpeechState} the mutable state associated with that target. This will be created if necessary. * @private */ _getState (target) { let state = target.getCustomState(Scratch3Text2SpeechBlocks.STATE_KEY); if (!state) { state = Clone.simple(Scratch3Text2SpeechBlocks.DEFAULT_TEXT2SPEECH_STATE); target.setCustomState(Scratch3Text2SpeechBlocks.STATE_KEY, state); } return state; } /** * When a Target is cloned, clone the state. * @param {Target} newTarget - the newly created target. * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. * @listens Runtime#event:targetWasCreated * @private */ _onTargetCreated (newTarget, sourceTarget) { if (sourceTarget) { const state = sourceTarget.getCustomState(Scratch3Text2SpeechBlocks.STATE_KEY); if (state) { newTarget.setCustomState(Scratch3Text2SpeechBlocks.STATE_KEY, Clone.simple(state)); } } } /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { // Only localize the default input to the "speak" block if we are in a // supported language. let defaultTextToSpeak = 'hello'; if (this.isSupportedLanguage(this.getEditorLanguage())) { defaultTextToSpeak = formatMessage({ id: 'text2speech.defaultTextToSpeak', default: 'hello', description: 'hello: the default text to speak' }); } return { id: 'text2speech', name: formatMessage({ id: 'text2speech.categoryName', default: 'Text to Speech', description: 'Name of the Text to Speech extension.' }), blockIconURI: blockIconURI, menuIconURI: menuIconURI, blocks: [ { opcode: 'speakAndWait', text: formatMessage({ id: 'text2speech.speakAndWaitBlock', default: 'speak [WORDS]', description: 'Speak some words.' }), blockType: BlockType.COMMAND, arguments: { WORDS: { type: ArgumentType.STRING, defaultValue: defaultTextToSpeak } } }, { opcode: 'setVoice', text: formatMessage({ id: 'text2speech.setVoiceBlock', default: 'set voice to [VOICE]', description: 'Set the voice for speech synthesis.' }), blockType: BlockType.COMMAND, arguments: { VOICE: { type: ArgumentType.STRING, menu: 'voices', defaultValue: ALTO_ID } } }, { opcode: 'setLanguage', text: formatMessage({ id: 'text2speech.setLanguageBlock', default: 'set language to [LANGUAGE]', description: 'Set the language for speech synthesis.' }), blockType: BlockType.COMMAND, arguments: { LANGUAGE: { type: ArgumentType.STRING, menu: 'languages', defaultValue: this.getCurrentLanguage() } } } ], menus: { voices: { acceptReporters: true, items: this.getVoiceMenu() }, languages: { acceptReporters: true, items: this.getLanguageMenu() } } }; } /** * Get the language code currently set in the editor, or fall back to the * browser locale. * @return {string} a Scratch locale code. */ getEditorLanguage () { const locale = formatMessage.setup().locale || navigator.language || navigator.userLanguage || this.DEFAULT_LANGUAGE; return locale.toLowerCase(); } /** * Get the language code currently set for the extension. * @returns {string} a Scratch locale code. */ getCurrentLanguage () { const stage = this.runtime.getTargetForStage(); if (!stage) return this.DEFAULT_LANGUAGE; // If no language has been set, set it to the editor locale (or default). if (!stage.textToSpeechLanguage) { this.setCurrentLanguage(this.getEditorLanguage()); } return stage.textToSpeechLanguage; } /** * Set the language code for the extension. * It is stored in the stage so it can be saved and loaded with the project. * @param {string} locale a locale code. */ setCurrentLanguage (locale) { const stage = this.runtime.getTargetForStage(); if (!stage) return; if (this.isSupportedLanguage(locale)) { stage.textToSpeechLanguage = this._getExtensionLocaleForSupportedLocale(locale); } // Support language names dropped onto the menu via reporter block // such as a variable containing a language name (in any language), // or the translate extension's language reporter. const localeForDroppedName = languageNames.nameMap[locale.toLowerCase()]; if (localeForDroppedName && this.isSupportedLanguage(localeForDroppedName)) { stage.textToSpeechLanguage = this._getExtensionLocaleForSupportedLocale(localeForDroppedName); } // If the language is null, set it to the default language. // This can occur e.g. if the extension was loaded with the editor // set to a language that is not in the list. if (!stage.textToSpeechLanguage) { stage.textToSpeechLanguage = this.DEFAULT_LANGUAGE; } } /** * 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. * @param {string} languageCode the language code to check. * @returns {boolean} true if the language code is supported. */ isSupportedLanguage (languageCode) { return this._supportedLocales.includes(languageCode); } /** * Get the menu of voices for the "set voice" block. * @return {array} the text and value for each menu item. */ getVoiceMenu () { return Object.keys(this.VOICE_INFO).map(voiceId => ({ text: this.VOICE_INFO[voiceId].name, value: voiceId })); } /** * Get the localized menu of languages for the "set language" block. * For each language: * if there is a custom translated spoken language name, use that; * otherwise use the translation in the languageNames menuMap; * otherwise fall back to the untranslated name in LANGUAGE_INFO. * @return {array} the text and value for each menu item. */ getLanguageMenu () { const editorLanguage = this.getEditorLanguage(); // Get the array of localized language names const localizedNameMap = {}; let nameArray = languageNames.menuMap[editorLanguage]; if (nameArray) { // Also get any localized names of spoken languages let spokenNameArray = []; if (languageNames.spokenLanguages) { spokenNameArray = languageNames.spokenLanguages[editorLanguage]; nameArray = nameArray.concat(spokenNameArray); } // Create a map of language code to localized name // The localized spoken language names have been concatenated onto // the end of the name array, so the result of the forEach below is // when there is both a written language name (e.g. 'Chinese // (simplified)') and a spoken language name (e.g. 'Chinese // (Mandarin)', we always use the spoken version. nameArray.forEach(lang => { localizedNameMap[lang.code] = lang.name; }); } return Object.keys(this.LANGUAGE_INFO).map(key => { let name = this.LANGUAGE_INFO[key].name; const localizedName = localizedNameMap[key]; if (localizedName) { name = localizedName; } // Uppercase the first character of the name name = name.charAt(0).toUpperCase() + name.slice(1); return { text: name, value: key }; }); } /** * Set the voice for speech synthesis for this sprite. * @param {object} args Block arguments * @param {object} util Utility object provided by the runtime. */ setVoice (args, util) { const state = this._getState(util.target); let voice = args.VOICE; // If the arg is a dropped number, treat it as a voice index let voiceNum = parseInt(voice, 10); if (!isNaN(voiceNum)) { voiceNum -= 1; // Treat dropped args as one-indexed voiceNum = MathUtil.wrapClamp(voiceNum, 0, Object.keys(this.VOICE_INFO).length - 1); voice = Object.keys(this.VOICE_INFO)[voiceNum]; } // Only set the voice if the arg is a valid voice id. if (Object.keys(this.VOICE_INFO).includes(voice)) { state.voiceId = voice; } } /** * Set the language for speech synthesis. * @param {object} args Block arguments */ setLanguage (args) { this.setCurrentLanguage(args.LANGUAGE); } /** * Stop all currently playing speech sounds. */ _stopAllSpeech () { this._soundPlayers.forEach(player => { player.stop(); }); } /** * Convert the provided text into a sound file and then play the file. * @param {object} args Block arguments * @param {object} util Utility object provided by the runtime. * @return {Promise} A promise that resolves after playing the sound */ speakAndWait (args, util) { // Cast input to string let words = Cast.toString(args.WORDS); let locale = this._getSpeechSynthLocale(); const state = this._getState(util.target); let gender = this.VOICE_INFO[state.voiceId].gender; 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) { words = words.replace(/\S+/g, 'meow'); locale = this.LANGUAGE_INFO[this.DEFAULT_LANGUAGE].speechSynthLocale; } // Build up URL let path = `${SERVER_HOST}/synth`; path += `?locale=${locale}`; path += `&gender=${gender}`; path += `&text=${encodeURIComponent(words.substring(0, 128))}`; // Perform HTTP request to get audio file return fetchWithTimeout(path, {}, SERVER_TIMEOUT) .then(res => { if (res.status !== 200) { throw new Error(`HTTP ${res.status} error reaching translation service`); } return res.arrayBuffer(); }) .then(buffer => { // Play the sound const sound = { data: { buffer } }; return this.runtime.audioEngine.decodeSoundPlayer(sound); }) .then(soundPlayer => { this._soundPlayers.set(soundPlayer.id, soundPlayer); soundPlayer.setPlaybackRate(playbackRate); // Increase the volume const engine = this.runtime.audioEngine; const chain = engine.createEffectChain(); chain.set('volume', SPEECH_VOLUME); soundPlayer.connect(chain); soundPlayer.play(); return new Promise(resolve => { soundPlayer.on('stop', () => { this._soundPlayers.delete(soundPlayer.id); resolve(); }); }); }) .catch(err => { log.warn(err); }); } } module.exports = Scratch3Text2SpeechBlocks;