// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.components.runtime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import android.media.AudioManager;
import android.util.Log;
import com.google.appinventor.components.annotations.DesignerComponent;
import com.google.appinventor.components.annotations.DesignerProperty;
import com.google.appinventor.components.annotations.PropertyCategory;
import com.google.appinventor.components.annotations.SimpleEvent;
import com.google.appinventor.components.annotations.SimpleFunction;
import com.google.appinventor.components.annotations.SimpleObject;
import com.google.appinventor.components.annotations.SimpleProperty;
import com.google.appinventor.components.common.ComponentCategory;
import com.google.appinventor.components.common.PropertyTypeConstants;
import com.google.appinventor.components.common.YaVersion;
import com.google.appinventor.components.runtime.collect.Maps;
import com.google.appinventor.components.runtime.util.ErrorMessages;
import com.google.appinventor.components.runtime.util.ExternalTextToSpeech;
import com.google.appinventor.components.runtime.util.ITextToSpeech;
import com.google.appinventor.components.runtime.util.InternalTextToSpeech;
import com.google.appinventor.components.runtime.util.SdkLevel;
import com.google.appinventor.components.runtime.util.YailList;
/**
* Component for converting text to speech using either the built-in TTS library or the
* TextToSpeech Extended library (which must be pre-installed for Android versions earlier
* than Donut).
*
* @author markf@google.com (Mark Friedman)
*/
// TODO(hal): This language and country code method using strings as abbreviations was
// deprecated in API level 21.
@DesignerComponent(version = YaVersion.TEXTTOSPEECH_COMPONENT_VERSION,
description = "The TestToSpeech component speaks a given text aloud. You can set " +
"the pitch and the rate of speech. " +
"<p>You can also set a language by supplying a language code. This changes the pronounciation " +
"of words, not the actual language spoken. For example, setting the language to French " +
"and speaking English text will sound like someone speaking English (en) with a French accent.</p> " +
"<p>You can also specify a country by supplying a country code. This can affect the " +
"pronounciation. For example, British English (GBR) will sound different from US English " +
"(USA). Not every country code will affect every language.</p> " +
"<p>The languages and countries available depend on the particular device, and can be listed " +
"with the AvailableLanguages and AvailableCountries properties.</p>",
category = ComponentCategory.MEDIA,
nonVisible = true,
iconName = "images/textToSpeech.png")
@SimpleObject
public class TextToSpeech extends AndroidNonvisibleComponent
implements Component, OnStopListener, OnResumeListener, OnDestroyListener /*, ActivityResultListener */{
private static final Map<String, Locale> iso3LanguageToLocaleMap = Maps.newHashMap();
private static final Map<String, Locale> iso3CountryToLocaleMap = Maps.newHashMap();
private float pitch = 1.0f;
private float speechRate = 1.0f;
private static final String LOG_TAG = "TextToSpeech";
static {
initLocaleMaps();
}
// List of available languages for the TTS
private ArrayList<String> languageList;
// List of available ISO3 country codes for the TTS
// the might be more countries than this, but we should update TTS control
// to use voices and clarify the use of countries with TTS
private ArrayList<String> countryList;
private YailList allLanguages;
private YailList allCountries;
private boolean result;
private String language;
private String country;
private final ITextToSpeech tts;
private String iso2Language;
private String iso2Country;
private boolean isTtsPrepared;
private static void initLocaleMaps() {
Locale[] locales = Locale.getAvailableLocales();
for (Locale locale : locales) {
try {
String iso3Country = locale.getISO3Country();
if (iso3Country.length() > 0) {
iso3CountryToLocaleMap.put(iso3Country, locale);
}
} catch (MissingResourceException e) {
// ignore;
}
try {
String iso3Language = locale.getISO3Language();
if (iso3Language.length() > 0) {
iso3LanguageToLocaleMap.put(iso3Language, locale);
}
} catch (MissingResourceException e) {
// ignore;
}
}
}
/**
* Creates a TextToSpeech component.
*
* @param container container, component will be placed in
*/
public TextToSpeech(ComponentContainer container) {
super(container.$form());
result = false;
Language(Component.DEFAULT_VALUE_TEXT_TO_SPEECH_LANGUAGE);
Country(Component.DEFAULT_VALUE_TEXT_TO_SPEECH_COUNTRY);
/* Determine which TTS library to use */
boolean useExternalLibrary = SdkLevel.getLevel() < SdkLevel.LEVEL_DONUT;
Log.v(LOG_TAG, "Using " + (useExternalLibrary ? "external" : "internal") + " TTS library.");
ITextToSpeech.TextToSpeechCallback callback = new ITextToSpeech.TextToSpeechCallback() {
@Override
public void onSuccess() {
result = true;
AfterSpeaking(true);
}
@Override
public void onFailure() {
result = false;
AfterSpeaking(false);
}
};
tts = useExternalLibrary ? new ExternalTextToSpeech(container, callback)
: new InternalTextToSpeech(container.$context(), callback);
// Set up listeners
form.registerForOnStop(this);
form.registerForOnResume(this);
form.registerForOnDestroy(this);
// Make volume buttons control media, not ringer.
form.setVolumeControlStream(AudioManager.STREAM_MUSIC);
isTtsPrepared = false;
languageList = new ArrayList<String>();
countryList = new ArrayList<String>();
allLanguages = YailList.makeList(languageList);
allCountries = YailList.makeList(countryList);
}
/**
* Result property getter method.
*/
@SimpleProperty(
category = PropertyCategory.BEHAVIOR)
public boolean Result() {
return result;
}
/**
* Sets the language for this TextToSpeech component.
*
* @param language is the ISO2 (i.e. 2 letter) or ISO3 (i.e. 3 letter) language code to set this
* TextToSpeech component to.
*/
@DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_TEXT_TO_SPEECH_LANGUAGES,
defaultValue = Component.DEFAULT_VALUE_TEXT_TO_SPEECH_LANGUAGE)
@SimpleProperty(category = PropertyCategory.BEHAVIOR,
description = "Sets the language for TextToSpeech. This changes the way that words are " +
"pronounced, not the actual language that is spoken. For example setting the language to " +
"and speaking English text with sound like someone speaking English with a Frernch accent.")
public void Language(String language) {
Locale locale;
switch (language.length()) {
case 3:
locale = iso3LanguageToLocale(language);
this.language = locale.getISO3Language();
break;
case 2:
locale = new Locale(language);
this.language = locale.getLanguage();
break;
default:
locale = Locale.getDefault();
this.language = locale.getLanguage();
break;
}
iso2Language = locale.getLanguage();
}
private static Locale iso3LanguageToLocale(String iso3Language) {
Locale mappedLocale = iso3LanguageToLocaleMap.get(iso3Language);
if (mappedLocale == null) {
// Language codes should be lower case, but maybe the user doesn't know that.
mappedLocale = iso3LanguageToLocaleMap.get(iso3Language.toLowerCase(Locale.ENGLISH));
}
return mappedLocale == null ? Locale.getDefault() : mappedLocale;
}
/**
* Sets the speech pitch for the TextToSpeech. 1.0 is the normal pitch, lower values lower the tone of
* the synthesized voice, greater values increase it.
*
* @param pitch a pitch level between 0 and 2
*/
@DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_FLOAT, defaultValue = "1.0")
@SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Sets the Pitch for " +
"TextToSpeech The values should " +
"be between 0 and 2 where lower values lower the tone of synthesized voice and greater values " +
"raise it.")
public void Pitch(float pitch) {
if (pitch < 0 || pitch > 2) {
Log.i(LOG_TAG, "Pitch value should be between 0 and 2, but user specified: " + pitch);
return;
}
this.pitch = pitch;
/* Lowest pitch value should be > 0. If 0, we just set to .1f
* Rather than having user specify .1, we just check and if 0, we set to .1
*/
tts.setPitch(pitch==0?.1f:pitch);
}
/**
* Reports the current value of speech pitch
*/
@SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Returns current value of Pitch")
public float Pitch() {
return this.pitch;
}
/**
* Sets the speech rate
*
* @param speechRate Speech rate 1.0 is the normal speech rate, lower values slow down the
* speech (0.5 is half the normal speech rate), greater values
* accelerate it (2.0 is twice the normal speech rate).
*/
@DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_FLOAT, defaultValue = "1.0")
@SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Sets the SpeechRate for TextToSpeech. " +
"The values should be between 0 and 2 where lower values slow down the pitch and greater values " +
"accelerate it.")
public void SpeechRate(float speechRate) {
if (speechRate < 0 || speechRate > 2) {
Log.i(LOG_TAG, "speechRate value should be between 0 and 2, but user specified: " + speechRate);
return;
}
this.speechRate = speechRate;
/* Lowest value should be > 0. If 0, we just set to .1f
* Rather than having user specify .1, we just check and if 0, we set to .1
*/
tts.setSpeechRate(speechRate == 0 ? .1f : speechRate);
}
/**
* Reports the current value of speechRate
*/
@SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Returns current value of SpeechRate")
public float SpeechRate() {
return this.speechRate;
}
/**
* Gets the language for this TextToSpeech component. This will be either an ISO2 (i.e. 2 letter)
* or ISO3 (i.e. 3 letter) code depending on which kind of code the property was set with.
*
* @return the language code for this TextToSpeech component.
*/
@SimpleProperty
public String Language() {
return language;
}
/**
* Sets the country for this TextToSpeech component.
*
* @param country is the ISO2 (i.e. 2 letter) or ISO3 (i.e. 3 letter) country code to set this
* TextToSpeech component to.
*/
@DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_TEXT_TO_SPEECH_COUNTRIES,
defaultValue = Component.DEFAULT_VALUE_TEXT_TO_SPEECH_COUNTRY)
@SimpleProperty(description = "Country code to use for speech generation. This can affect the " +
"pronounciation. For example, British English (GBR) will sound different from US English " +
"(USA). Not every country code will affect every language.",
category = PropertyCategory.BEHAVIOR)
public void Country(String country) {
Locale locale;
switch (country.length()) {
case 3:
locale = iso3CountryToLocale(country);
this.country = locale.getISO3Country();
break;
case 2:
locale = new Locale(country);
this.country = locale.getCountry();
break;
default:
locale = Locale.getDefault();
this.country = locale.getCountry();
break;
}
iso2Country = locale.getCountry();
}
private static Locale iso3CountryToLocale(String iso3Country) {
Locale mappedLocale = iso3CountryToLocaleMap.get(iso3Country);
if (mappedLocale == null) {
// Country codes should be upper case, but maybe the user doesn't know that.
mappedLocale = iso3CountryToLocaleMap.get(iso3Country.toUpperCase(Locale.ENGLISH));
}
return mappedLocale == null ? Locale.getDefault() : mappedLocale;
}
/**
* Gets the country for this TextToSpeech component. This will be either an ISO2 (i.e. 2 letter)
* or ISO3 (i.e. 3 letter) code depending on which kind of code the property was set with.
*
* @return country code for this TextToSpeech component.
*/
@SimpleProperty
public String Country() {
return country;
}
@SimpleProperty(description = "List of the languages available on this device " +
"for use with TextToSpeech. Check the Android developer documentation under supported " +
"languages to find the meanings of these abbreviations.")
public YailList AvailableLanguages() {
prepareLanguageAndCountryProperties();
return allLanguages;
}
@SimpleProperty(description = "List of the country codes available on this device " +
"for use with TextToSpeech. Check the Android developer documentation under supported " +
"languages to find the meanings of these abbreviations.")
public YailList AvailableCountries() {
prepareLanguageAndCountryProperties();
return allCountries;
}
public void prepareLanguageAndCountryProperties() {
if (!isTtsPrepared) {
if (!tts.isInitialized()) {
form.dispatchErrorOccurredEvent(this, "TextToSpeech",
ErrorMessages.ERROR_TTS_NOT_READY);
// Force the TTS engine to initialize by making it speak.
// If it's not ready the user will have to try again.
// Should we put a retry wait here?
Speak("");
} else {
getLanguageAndCountryLists();
isTtsPrepared = true;
}
}
}
/**
* Get list of available languages for TextToSpeech. Do not call unless the TTS
* engine is initialized.
*
*/
private void getLanguageAndCountryLists() {
// We do compute these lists pre-Donut. We probably could
// arrange to also do this in earlier releases, but that would be
// relying on the use of an external textToSpeech application and those
// old releases are obsolete anyway.
if (SdkLevel.getLevel() >= SdkLevel.LEVEL_DONUT) {
String tempLang;
String tempCountry;
for (Locale locale : Locale.getAvailableLocales()) {
// isLanguageAvailable requires tts to be initialized
int res = tts.isLanguageAvailable(locale);
if (!(res == android.speech.tts.TextToSpeech.LANG_NOT_SUPPORTED)){
tempLang = locale.getLanguage();
// We record only the ISO3 country codes for now. We should update the TTS control
// to use voices, and then we can straighten this out, maybe getting rid of
// country modifiers in TTS altogether.
tempCountry = locale.getISO3Country();
if (!tempLang.equals("") && (!languageList.contains(tempLang))){
languageList.add(tempLang);
}
if (!tempCountry.equals("") && (!countryList.contains(tempCountry))){
countryList.add(tempCountry);
}
}
}
Collections.sort(languageList);
Collections.sort(countryList);
allLanguages = YailList.makeList(languageList);
allCountries = YailList.makeList(countryList);
}
}
/**
* Speaks the given message.
*/
@SimpleFunction
public void Speak(final String message) {
BeforeSpeaking();
final Locale loc = new Locale(iso2Language, iso2Country);
tts.speak(message, loc);
}
/**
* Event to raise when Speak is invoked, before the message is spoken.
*/
@SimpleEvent
public void BeforeSpeaking() {
EventDispatcher.dispatchEvent(this, "BeforeSpeaking");
}
/**
* Event to raise after the message is spoken.
*
* @param result whether the message was successfully spoken
*/
@SimpleEvent
public void AfterSpeaking(boolean result) {
EventDispatcher.dispatchEvent(this, "AfterSpeaking", result);
}
@Override
public void onStop() {
// tts.onStop in fact does nothing, but we'll keep this onStop here for flexibility
tts.onStop();
}
@Override
public void onResume() {
tts.onResume();
}
@Override
public void onDestroy() {
tts.onDestroy();
}
}