diff options
author | Niels Egberts <nielse@google.com> | 2014-05-14 13:01:29 +0100 |
---|---|---|
committer | Niels Egberts <nielse@google.com> | 2014-05-30 13:48:09 +0000 |
commit | a6cc9b85ad7d4fdb4fef9666563c91bd878631f5 (patch) | |
tree | f163cda082aef1c597faaa4fb671ba7c2397363b /core/java/android/speech | |
parent | 55cef957a370de208374c36ba4a27a69652f0965 (diff) | |
download | frameworks_base-a6cc9b85ad7d4fdb4fef9666563c91bd878631f5.zip frameworks_base-a6cc9b85ad7d4fdb4fef9666563c91bd878631f5.tar.gz frameworks_base-a6cc9b85ad7d4fdb4fef9666563c91bd878631f5.tar.bz2 |
Markup support for framework
Change-Id: Ia5ad6cff7593c295944a90775a1b061c95f5cc3f
Diffstat (limited to 'core/java/android/speech')
-rw-r--r-- | core/java/android/speech/tts/Markup.java | 537 | ||||
-rw-r--r-- | core/java/android/speech/tts/SynthesisRequestV2.java | 38 | ||||
-rw-r--r-- | core/java/android/speech/tts/TextToSpeechClient.java | 67 | ||||
-rw-r--r-- | core/java/android/speech/tts/TextToSpeechService.java | 53 | ||||
-rw-r--r-- | core/java/android/speech/tts/Utterance.java | 595 |
5 files changed, 1269 insertions, 21 deletions
diff --git a/core/java/android/speech/tts/Markup.java b/core/java/android/speech/tts/Markup.java new file mode 100644 index 0000000..c886e5d --- /dev/null +++ b/core/java/android/speech/tts/Markup.java @@ -0,0 +1,537 @@ +package android.speech.tts; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +/** + * A class that provides markup to a synthesis request to control aspects of speech. + * <p> + * Markup itself is a feature agnostic data format; the {@link Utterance} class defines the currently + * available set of features and should be used to construct instances of the Markup class. + * </p> + * <p> + * A marked up sentence is a tree. Each node has a type, an optional plain text, a set of + * parameters, and a list of children. + * The <b>type</b> defines what it contains, e.g. "text", "date", "measure", etc. A Markup node + * can be either a part of sentence (often a leaf node), or node altering some property of its + * children (node with children). The top level node has to be of type "utterance" and its children + * are synthesized in order. + * The <b>plain text</b> is optional except for the top level node. If the synthesis engine does not + * support Markup at all, it should use the plain text of the top level node. If an engine does not + * recognize or support a node type, it will try to use the plain text of that node if provided. If + * the plain text is null, it will synthesize its children in order. + * <b>Parameters</b> are key-value pairs specific to each node type. In case of a date node the + * parameters may be for example "month: 7" and "day: 10". + * The <b>nested markups</b> are children and can for example be used to nest semiotic classes (a + * measure may have a node of type "decimal" as its child) or to modify some property of its + * children. See "plain text" on how they are processed if the parent of the children is unknown to + * the engine. + * <p> + */ +public final class Markup implements Parcelable { + + private String mType; + private String mPlainText; + + private Bundle mParameters = new Bundle(); + private List<Markup> mNestedMarkups = new ArrayList<Markup>(); + + private static final String TYPE = "type"; + private static final String PLAIN_TEXT = "plain_text"; + private static final String MARKUP = "markup"; + + private static final String IDENTIFIER_REGEX = "([0-9a-z_]+)"; + private static final Pattern legalIdentifierPattern = Pattern.compile(IDENTIFIER_REGEX); + + /** + * Constructs an empty markup. + */ + public Markup() {} + + /** + * Constructs a markup of the given type. + */ + public Markup(String type) { + setType(type); + } + + /** + * Returns the type of this node; can be null. + */ + public String getType() { + return mType; + } + + /** + * Sets the type of this node. can be null. May only contain [0-9a-z_]. + */ + public void setType(String type) { + if (type != null) { + Matcher matcher = legalIdentifierPattern.matcher(type); + if (!matcher.matches()) { + throw new IllegalArgumentException("Type cannot be empty and may only contain " + + "0-9, a-z and underscores."); + } + } + mType = type; + } + + /** + * Returns this node's plain text; can be null. + */ + public String getPlainText() { + return mPlainText; + } + + /** + * Sets this nodes's plain text; can be null. + */ + public void setPlainText(String plainText) { + mPlainText = plainText; + } + + /** + * Adds or modifies a parameter. + * @param key The key; may only contain [0-9a-z_] and cannot be "type" or "plain_text". + * @param value The value. + * @throws An {@link IllegalArgumentException} if the key is null or empty. + * @return this + */ + public Markup setParameter(String key, String value) { + if (key == null || key.isEmpty()) { + throw new IllegalArgumentException("Key cannot be null or empty."); + } + if (key.equals("type")) { + throw new IllegalArgumentException("Key cannot be \"type\"."); + } + if (key.equals("plain_text")) { + throw new IllegalArgumentException("Key cannot be \"plain_text\"."); + } + Matcher matcher = legalIdentifierPattern.matcher(key); + if (!matcher.matches()) { + throw new IllegalArgumentException("Key may only contain 0-9, a-z and underscores."); + } + + if (value != null) { + mParameters.putString(key, value); + } else { + removeParameter(key); + } + return this; + } + + /** + * Removes the parameter with the given key + */ + public void removeParameter(String key) { + mParameters.remove(key); + } + + /** + * Returns the value of the parameter. + * @param key The parameter key. + * @return The value of the parameter or null if the parameter is not set. + */ + public String getParameter(String key) { + return mParameters.getString(key); + } + + /** + * Returns the number of parameters that have been set. + */ + public int parametersSize() { + return mParameters.size(); + } + + /** + * Appends a child to the list of children + * @param markup The child. + * @return This instance. + * @throws {@link IllegalArgumentException} if markup is null. + */ + public Markup addNestedMarkup(Markup markup) { + if (markup == null) { + throw new IllegalArgumentException("Nested markup cannot be null"); + } + mNestedMarkups.add(markup); + return this; + } + + /** + * Removes the given node from its children. + * @param markup The child to remove. + * @return True if this instance was modified by this operation, false otherwise. + */ + public boolean removeNestedMarkup(Markup markup) { + return mNestedMarkups.remove(markup); + } + + /** + * Returns the index'th child. + * @param i The index of the child. + * @return The child. + * @throws {@link IndexOutOfBoundsException} if i < 0 or i >= nestedMarkupSize() + */ + public Markup getNestedMarkup(int i) { + return mNestedMarkups.get(i); + } + + + /** + * Returns the number of children. + */ + public int nestedMarkupSize() { + return mNestedMarkups.size(); + } + + /** + * Returns a string representation of this Markup instance. Can be deserialized back to a Markup + * instance with markupFromString(). + */ + public String toString() { + StringBuilder out = new StringBuilder(); + if (mType != null) { + out.append(TYPE + ": \"" + mType + "\""); + } + if (mPlainText != null) { + out.append(out.length() > 0 ? " " : ""); + out.append(PLAIN_TEXT + ": \"" + escapeQuotedString(mPlainText) + "\""); + } + // Sort the parameters alphabetically by key so we have a stable output. + SortedMap<String, String> sortedMap = new TreeMap<String, String>(); + for (String key : mParameters.keySet()) { + sortedMap.put(key, mParameters.getString(key)); + } + for (Map.Entry<String, String> entry : sortedMap.entrySet()) { + out.append(out.length() > 0 ? " " : ""); + out.append(entry.getKey() + ": \"" + escapeQuotedString(entry.getValue()) + "\""); + } + for (Markup m : mNestedMarkups) { + out.append(out.length() > 0 ? " " : ""); + String nestedStr = m.toString(); + if (nestedStr.isEmpty()) { + out.append(MARKUP + " {}"); + } else { + out.append(MARKUP + " { " + m.toString() + " }"); + } + } + return out.toString(); + } + + /** + * Escapes backslashes and double quotes in the plain text and parameter values before this + * instance is written to a string. + * @param str The string to escape. + * @return The escaped string. + */ + private static String escapeQuotedString(String str) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (str.charAt(i) == '\\') { + out.append("\\\\"); + } else { + out.append(c); + } + } + return out.toString(); + } + + /** + * The reverse of the escape method, returning plain text and parameter values to their original + * form. + * @param str An escaped string. + * @return The unescaped string. + */ + private static String unescapeQuotedString(String str) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '\\') { + i++; + if (i >= str.length()) { + throw new IllegalArgumentException("Unterminated escape sequence in string: " + + str); + } + c = str.charAt(i); + if (c == '\\') { + out.append("\\"); + } else if (c == '"') { + out.append("\""); + } else { + throw new IllegalArgumentException("Unsupported escape sequence: \\" + c + + " in string " + str); + } + } else { + out.append(c); + } + } + return out.toString(); + } + + /** + * Returns true if the given string consists only of whitespace. + * @param str The string to check. + * @return True if the given string consists only of whitespace. + */ + private static boolean isWhitespace(String str) { + return Pattern.matches("\\s*", str); + } + + /** + * Parses the given string, and overrides the values of this instance with those contained + * in the given string. + * @param str The string to parse; can have superfluous whitespace. + * @return An empty string on success, else the remainder of the string that could not be + * parsed. + */ + private String fromReadableString(String str) { + while (!isWhitespace(str)) { + String newStr = matchValue(str); + if (newStr == null) { + newStr = matchMarkup(str); + + if (newStr == null) { + return str; + } + } + str = newStr; + } + return ""; + } + + // Matches: key : "value" + // where key is an identifier and value can contain escaped quotes + // there may be superflouous whitespace + // The value string may contain quotes and backslashes. + private static final String OPTIONAL_WHITESPACE = "\\s*"; + private static final String VALUE_REGEX = "((\\\\.|[^\\\"])*)"; + private static final String KEY_VALUE_REGEX = + "\\A" + OPTIONAL_WHITESPACE + // start of string + IDENTIFIER_REGEX + OPTIONAL_WHITESPACE + ":" + OPTIONAL_WHITESPACE + // key: + "\"" + VALUE_REGEX + "\""; // "value" + private static final Pattern KEY_VALUE_PATTERN = Pattern.compile(KEY_VALUE_REGEX); + + /** + * Tries to match a key-value pair at the start of the string. If found, add that as a parameter + * of this instance. + * @param str The string to parse. + * @return The remainder of the string without the parsed key-value pair on success, else null. + */ + private String matchValue(String str) { + // Matches: key: "value" + Matcher matcher = KEY_VALUE_PATTERN.matcher(str); + if (!matcher.find()) { + return null; + } + String key = matcher.group(1); + String value = matcher.group(2); + + if (key == null || value == null) { + return null; + } + String unescapedValue = unescapeQuotedString(value); + if (key.equals(TYPE)) { + this.mType = unescapedValue; + } else if (key.equals(PLAIN_TEXT)) { + this.mPlainText = unescapedValue; + } else { + setParameter(key, unescapedValue); + } + + return str.substring(matcher.group(0).length()); + } + + // matches 'markup {' + private static final Pattern OPEN_MARKUP_PATTERN = + Pattern.compile("\\A" + OPTIONAL_WHITESPACE + MARKUP + OPTIONAL_WHITESPACE + "\\{"); + // matches '}' + private static final Pattern CLOSE_MARKUP_PATTERN = + Pattern.compile("\\A" + OPTIONAL_WHITESPACE + "\\}"); + + /** + * Tries to parse a Markup specification from the start of the string. If so, add that markup to + * the list of nested Markup's of this instance. + * @param str The string to parse. + * @return The remainder of the string without the parsed Markup on success, else null. + */ + private String matchMarkup(String str) { + // find and strip "markup {" + Matcher matcher = OPEN_MARKUP_PATTERN.matcher(str); + + if (!matcher.find()) { + return null; + } + String strRemainder = str.substring(matcher.group(0).length()); + // parse and strip markup contents + Markup nestedMarkup = new Markup(); + strRemainder = nestedMarkup.fromReadableString(strRemainder); + + // find and strip "}" + Matcher matcherClose = CLOSE_MARKUP_PATTERN.matcher(strRemainder); + if (!matcherClose.find()) { + return null; + } + strRemainder = strRemainder.substring(matcherClose.group(0).length()); + + // Everything parsed, add markup + this.addNestedMarkup(nestedMarkup); + + // Return remainder + return strRemainder; + } + + /** + * Returns a Markup instance from the string representation generated by toString(). + * @param string The string representation generated by toString(). + * @return The new Markup instance. + * @throws {@link IllegalArgumentException} if the input cannot be correctly parsed. + */ + public static Markup markupFromString(String string) throws IllegalArgumentException { + Markup m = new Markup(); + if (m.fromReadableString(string).isEmpty()) { + return m; + } else { + throw new IllegalArgumentException("Cannot parse input to Markup"); + } + } + + /** + * Compares the specified object with this Markup for equality. + * @return True if the given object is a Markup instance with the same type, plain text, + * parameters and the nested markups are also equal to each other and in the same order. + */ + @Override + public boolean equals(Object o) { + if ( this == o ) return true; + if ( !(o instanceof Markup) ) return false; + Markup m = (Markup) o; + + if (nestedMarkupSize() != this.nestedMarkupSize()) { + return false; + } + + if (!(mType == null ? m.mType == null : mType.equals(m.mType))) { + return false; + } + if (!(mPlainText == null ? m.mPlainText == null : mPlainText.equals(m.mPlainText))) { + return false; + } + if (!equalBundles(mParameters, m.mParameters)) { + return false; + } + + for (int i = 0; i < this.nestedMarkupSize(); i++) { + if (!mNestedMarkups.get(i).equals(m.mNestedMarkups.get(i))) { + return false; + } + } + + return true; + } + + /** + * Checks if two bundles are equal to each other. Used by equals(o). + */ + private boolean equalBundles(Bundle one, Bundle two) { + if (one == null || two == null) { + return false; + } + + if(one.size() != two.size()) { + return false; + } + + Set<String> valuesOne = one.keySet(); + for(String key : valuesOne) { + Object valueOne = one.get(key); + Object valueTwo = two.get(key); + if (valueOne instanceof Bundle && valueTwo instanceof Bundle && + !equalBundles((Bundle) valueOne, (Bundle) valueTwo)) { + return false; + } else if (valueOne == null) { + if (valueTwo != null || !two.containsKey(key)) { + return false; + } + } else if(!valueOne.equals(valueTwo)) { + return false; + } + } + return true; + } + + /** + * Returns an unmodifiable list of the children. + * @return An unmodifiable list of children that throws an {@link UnsupportedOperationException} + * if an attempt is made to modify it + */ + public List<Markup> getNestedMarkups() { + return Collections.unmodifiableList(mNestedMarkups); + } + + /** + * @hide + */ + public Markup(Parcel in) { + mType = in.readString(); + mPlainText = in.readString(); + mParameters = in.readBundle(); + in.readList(mNestedMarkups, Markup.class.getClassLoader()); + } + + /** + * Creates a deep copy of the given markup. + */ + public Markup(Markup markup) { + mType = markup.mType; + mPlainText = markup.mPlainText; + mParameters = markup.mParameters; + for (Markup nested : markup.getNestedMarkups()) { + addNestedMarkup(new Markup(nested)); + } + } + + /** + * @hide + */ + public int describeContents() { + return 0; + } + + /** + * @hide + */ + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mType); + dest.writeString(mPlainText); + dest.writeBundle(mParameters); + dest.writeList(mNestedMarkups); + } + + /** + * @hide + */ + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public Markup createFromParcel(Parcel in) { + return new Markup(in); + } + + public Markup[] newArray(int size) { + return new Markup[size]; + } + }; +} + diff --git a/core/java/android/speech/tts/SynthesisRequestV2.java b/core/java/android/speech/tts/SynthesisRequestV2.java index a1da49c..130e3f9 100644 --- a/core/java/android/speech/tts/SynthesisRequestV2.java +++ b/core/java/android/speech/tts/SynthesisRequestV2.java @@ -4,11 +4,12 @@ import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.speech.tts.TextToSpeechClient.UtteranceId; +import android.util.Log; /** * Service-side representation of a synthesis request from a V2 API client. Contains: * <ul> - * <li>The utterance to synthesize</li> + * <li>The markup object to synthesize containing the utterance.</li> * <li>The id of the utterance (String, result of {@link UtteranceId#toUniqueString()}</li> * <li>The synthesis voice name (String, result of {@link VoiceInfo#getName()})</li> * <li>Voice parameters (Bundle of parameters)</li> @@ -16,8 +17,11 @@ import android.speech.tts.TextToSpeechClient.UtteranceId; * </ul> */ public final class SynthesisRequestV2 implements Parcelable { - /** Synthesis utterance. */ - private final String mText; + + private static final String TAG = "SynthesisRequestV2"; + + /** Synthesis markup */ + private final Markup mMarkup; /** Synthesis id. */ private final String mUtteranceId; @@ -34,9 +38,9 @@ public final class SynthesisRequestV2 implements Parcelable { /** * Constructor for test purposes. */ - public SynthesisRequestV2(String text, String utteranceId, String voiceName, + public SynthesisRequestV2(Markup markup, String utteranceId, String voiceName, Bundle voiceParams, Bundle audioParams) { - this.mText = text; + this.mMarkup = markup; this.mUtteranceId = utteranceId; this.mVoiceName = voiceName; this.mVoiceParams = voiceParams; @@ -49,15 +53,18 @@ public final class SynthesisRequestV2 implements Parcelable { * @hide */ public SynthesisRequestV2(Parcel in) { - this.mText = in.readString(); + this.mMarkup = (Markup) in.readValue(Markup.class.getClassLoader()); this.mUtteranceId = in.readString(); this.mVoiceName = in.readString(); this.mVoiceParams = in.readBundle(); this.mAudioParams = in.readBundle(); } - SynthesisRequestV2(String text, String utteranceId, RequestConfig rconfig) { - this.mText = text; + /** + * Constructor to request the synthesis of a sentence. + */ + SynthesisRequestV2(Markup markup, String utteranceId, RequestConfig rconfig) { + this.mMarkup = markup; this.mUtteranceId = utteranceId; this.mVoiceName = rconfig.getVoice().getName(); this.mVoiceParams = rconfig.getVoiceParams(); @@ -71,7 +78,7 @@ public final class SynthesisRequestV2 implements Parcelable { */ @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeString(mText); + dest.writeValue(mMarkup); dest.writeString(mUtteranceId); dest.writeString(mVoiceName); dest.writeBundle(mVoiceParams); @@ -82,7 +89,18 @@ public final class SynthesisRequestV2 implements Parcelable { * @return the text which should be synthesized. */ public String getText() { - return mText; + if (mMarkup.getPlainText() == null) { + Log.e(TAG, "Plaintext of markup is null."); + return ""; + } + return mMarkup.getPlainText(); + } + + /** + * @return the markup which should be synthesized. + */ + public Markup getMarkup() { + return mMarkup; } /** diff --git a/core/java/android/speech/tts/TextToSpeechClient.java b/core/java/android/speech/tts/TextToSpeechClient.java index 85f702b..e17b498 100644 --- a/core/java/android/speech/tts/TextToSpeechClient.java +++ b/core/java/android/speech/tts/TextToSpeechClient.java @@ -512,7 +512,6 @@ public class TextToSpeechClient { } } - /** * Connects the client to TTS service. This method returns immediately, and connects to the * service in the background. @@ -876,7 +875,7 @@ public class TextToSpeechClient { private static final String ACTION_QUEUE_SPEAK_NAME = "queueSpeak"; /** - * Speaks the string using the specified queuing strategy using current + * Speaks the string using the specified queuing strategy and the current * voice. This method is asynchronous, i.e. the method just adds the request * to the queue of TTS requests and then returns. The synthesis might not * have finished (or even started!) at the time when this method returns. @@ -887,12 +886,35 @@ public class TextToSpeechClient { * in {@link RequestCallbacks}. * @param config Synthesis request configuration. Can't be null. Has to contain a * voice. - * @param callbacks Synthesis request callbacks. If null, default request + * @param callbacks Synthesis request callbacks. If null, the default request * callbacks object will be used. */ public void queueSpeak(final String utterance, final UtteranceId utteranceId, final RequestConfig config, final RequestCallbacks callbacks) { + queueSpeak(createMarkupFromString(utterance), utteranceId, config, callbacks); + } + + /** + * Speaks the {@link Markup} (which can be constructed with {@link Utterance}) using + * the specified queuing strategy and the current voice. This method is + * asynchronous, i.e. the method just adds the request to the queue of TTS + * requests and then returns. The synthesis might not have finished (or even + * started!) at the time when this method returns. + * + * @param markup The Markup to be spoken. The written equivalent of the spoken + * text should be no longer than 1000 characters. + * @param utteranceId Unique identificator used to track the synthesis progress + * in {@link RequestCallbacks}. + * @param config Synthesis request configuration. Can't be null. Has to contain a + * voice. + * @param callbacks Synthesis request callbacks. If null, the default request + * callbacks object will be used. + */ + public void queueSpeak(final Markup markup, + final UtteranceId utteranceId, + final RequestConfig config, + final RequestCallbacks callbacks) { runAction(new Action(ACTION_QUEUE_SPEAK_NAME) { @Override public void run(ITextToSpeechService service) throws RemoteException { @@ -908,7 +930,7 @@ public class TextToSpeechClient { int queueResult = service.speakV2( getCallerIdentity(), - new SynthesisRequestV2(utterance, utteranceId.toUniqueString(), config)); + new SynthesisRequestV2(markup, utteranceId.toUniqueString(), config)); if (queueResult != Status.SUCCESS) { removeCallbackAndErr(utteranceId.toUniqueString(), queueResult); } @@ -931,12 +953,37 @@ public class TextToSpeechClient { * @param outputFile File to write the generated audio data to. * @param config Synthesis request configuration. Can't be null. Have to contain a * voice. - * @param callbacks Synthesis request callbacks. If null, default request + * @param callbacks Synthesis request callbacks. If null, the default request * callbacks object will be used. */ public void queueSynthesizeToFile(final String utterance, final UtteranceId utteranceId, final File outputFile, final RequestConfig config, final RequestCallbacks callbacks) { + queueSynthesizeToFile(createMarkupFromString(utterance), utteranceId, outputFile, config, callbacks); + } + + /** + * Synthesizes the given {@link Markup} (can be constructed with {@link Utterance}) + * to a file using the specified parameters. This method is asynchronous, i.e. the + * method just adds the request to the queue of TTS requests and then returns. The + * synthesis might not have finished (or even started!) at the time when this method + * returns. + * + * @param markup The Markup that should be synthesized. The written equivalent of + * the spoken text should be no longer than 1000 characters. + * @param utteranceId Unique identificator used to track the synthesis progress + * in {@link RequestCallbacks}. + * @param outputFile File to write the generated audio data to. + * @param config Synthesis request configuration. Can't be null. Have to contain a + * voice. + * @param callbacks Synthesis request callbacks. If null, the default request + * callbacks object will be used. + */ + public void queueSynthesizeToFile( + final Markup markup, + final UtteranceId utteranceId, + final File outputFile, final RequestConfig config, + final RequestCallbacks callbacks) { runAction(new Action(ACTION_QUEUE_SYNTHESIZE_TO_FILE) { @Override public void run(ITextToSpeechService service) throws RemoteException { @@ -964,8 +1011,7 @@ public class TextToSpeechClient { int queueResult = service.synthesizeToFileDescriptorV2(getCallerIdentity(), fileDescriptor, - new SynthesisRequestV2(utterance, utteranceId.toUniqueString(), - config)); + new SynthesisRequestV2(markup, utteranceId.toUniqueString(), config)); fileDescriptor.close(); if (queueResult != Status.SUCCESS) { removeCallbackAndErr(utteranceId.toUniqueString(), queueResult); @@ -981,6 +1027,13 @@ public class TextToSpeechClient { }); } + private static Markup createMarkupFromString(String str) { + return new Utterance() + .append(new Utterance.TtsText(str)) + .setNoWarningOnFallback(true) + .createMarkup(); + } + private static final String ACTION_QUEUE_SILENCE_NAME = "queueSilence"; /** diff --git a/core/java/android/speech/tts/TextToSpeechService.java b/core/java/android/speech/tts/TextToSpeechService.java index 6b899d9..14a4024 100644 --- a/core/java/android/speech/tts/TextToSpeechService.java +++ b/core/java/android/speech/tts/TextToSpeechService.java @@ -352,6 +352,12 @@ public abstract class TextToSpeechService extends Service { params.putString(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS, "true"); } + String noWarning = request.getMarkup().getParameter(Utterance.KEY_NO_WARNING_ON_FALLBACK); + if (noWarning == null || noWarning.equals("false")) { + Log.w("TextToSpeechService", "The synthesis engine does not support Markup, falling " + + "back to the given plain text."); + } + // Build V1 request SynthesisRequest requestV1 = new SynthesisRequest(request.getText(), params); Locale locale = selectedVoice.getLocale(); @@ -856,14 +862,53 @@ public abstract class TextToSpeechService extends Service { } } + /** + * Estimate of the character count equivalent of a Markup instance. Calculated + * by summing the characters of all Markups of type "text". Each other node + * is counted as a single character, as the character count of other nodes + * is non-trivial to calculate and we don't want to accept arbitrarily large + * requests. + */ + private int estimateSynthesisLengthFromMarkup(Markup m) { + int size = 0; + if (m.getType() != null && + m.getType().equals("text") && + m.getParameter("text") != null) { + size += m.getParameter("text").length(); + } else if (m.getType() == null || + !m.getType().equals("utterance")) { + size += 1; + } + for (Markup nested : m.getNestedMarkups()) { + size += estimateSynthesisLengthFromMarkup(nested); + } + return size; + } + @Override public boolean isValid() { - if (mSynthesisRequest.getText() == null) { - Log.e(TAG, "null synthesis text"); + if (mSynthesisRequest.getMarkup() == null) { + Log.e(TAG, "No markup in request."); return false; } - if (mSynthesisRequest.getText().length() >= TextToSpeech.getMaxSpeechInputLength()) { - Log.w(TAG, "Text too long: " + mSynthesisRequest.getText().length() + " chars"); + String type = mSynthesisRequest.getMarkup().getType(); + if (type == null) { + Log.w(TAG, "Top level markup node should have type \"utterance\", not null"); + return false; + } else if (!type.equals("utterance")) { + Log.w(TAG, "Top level markup node should have type \"utterance\" instead of " + + "\"" + type + "\""); + return false; + } + + int estimate = estimateSynthesisLengthFromMarkup(mSynthesisRequest.getMarkup()); + if (estimate >= TextToSpeech.getMaxSpeechInputLength()) { + Log.w(TAG, "Text too long: estimated size of text was " + estimate + " chars."); + return false; + } + + if (estimate <= 0) { + Log.e(TAG, "null synthesis text"); return false; } diff --git a/core/java/android/speech/tts/Utterance.java b/core/java/android/speech/tts/Utterance.java new file mode 100644 index 0000000..0a29283 --- /dev/null +++ b/core/java/android/speech/tts/Utterance.java @@ -0,0 +1,595 @@ +package android.speech.tts; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class acts as a builder for {@link Markup} instances. + * <p> + * Each Utterance consists of a list of the semiotic classes ({@link Utterance.TtsCardinal} and + * {@link Utterance.TtsText}). + * <p>Each semiotic class can be supplied with morphosyntactic features + * (gender, animacy, multiplicity and case), it is up to the synthesis engine to use this + * information during synthesis. + * Examples where morphosyntactic features matter: + * <ul> + * <li>In French, the number one is verbalized differently based on the gender of the noun + * it modifies. "un homme" (one man) versus "une femme" (one woman). + * <li>In German the grammatical case (accusative, locative, etc) needs to be included to be + * verbalize correctly. In German you'd have the sentence "Sie haben 1 kilometer vor Ihnen" (You + * have 1 kilometer ahead of you), "1" in this case needs to become inflected to the accusative + * form ("einen") instead of the nominative form "ein". + * </p> + * <p> + * Utterance usage example: + * Markup m1 = new Utterance().append("The Eiffel Tower is") + * .append(new TtsCardinal(324)) + * .append("meters tall."); + * Markup m2 = new Utterance().append("Sie haben") + * .append(new TtsCardinal(1).setGender(Utterance.GENDER_MALE) + * .append("Tag frei."); + * </p> + */ +public class Utterance { + + /*** + * Toplevel type of markup representation. + */ + public static final String TYPE_UTTERANCE = "utterance"; + /*** + * The no_warning_on_fallback parameter can be set to "false" or "true", true indicating that + * no warning will be given when the synthesizer does not support Markup. This is used when + * the user only provides a string to the API instead of a markup. + */ + public static final String KEY_NO_WARNING_ON_FALLBACK = "no_warning_on_fallback"; + + // Gender. + public final static int GENDER_UNKNOWN = 0; + public final static int GENDER_NEUTRAL = 1; + public final static int GENDER_MALE = 2; + public final static int GENDER_FEMALE = 3; + + // Animacy. + public final static int ANIMACY_UNKNOWN = 0; + public final static int ANIMACY_ANIMATE = 1; + public final static int ANIMACY_INANIMATE = 2; + + // Multiplicity. + public final static int MULTIPLICITY_UNKNOWN = 0; + public final static int MULTIPLICITY_SINGLE = 1; + public final static int MULTIPLICITY_DUAL = 2; + public final static int MULTIPLICITY_PLURAL = 3; + + // Case. + public final static int CASE_UNKNOWN = 0; + public final static int CASE_NOMINATIVE = 1; + public final static int CASE_ACCUSATIVE = 2; + public final static int CASE_DATIVE = 3; + public final static int CASE_ABLATIVE = 4; + public final static int CASE_GENITIVE = 5; + public final static int CASE_VOCATIVE = 6; + public final static int CASE_LOCATIVE = 7; + public final static int CASE_INSTRUMENTAL = 8; + + private List<AbstractTts<? extends AbstractTts<?>>> says = + new ArrayList<AbstractTts<? extends AbstractTts<?>>>(); + Boolean mNoWarningOnFallback = null; + + /** + * Objects deriving from this class can be appended to a Utterance. This class uses generics + * so method from this class can return instances of its child classes, resulting in a better + * API (CRTP pattern). + */ + public static abstract class AbstractTts<C extends AbstractTts<C>> { + + protected Markup mMarkup = new Markup(); + + /** + * Empty constructor. + */ + protected AbstractTts() { + } + + /** + * Construct with Markup. + * @param markup + */ + protected AbstractTts(Markup markup) { + mMarkup = markup; + } + + /** + * Returns the type of this class, e.g. "cardinal" or "measure". + * @return The type. + */ + public String getType() { + return mMarkup.getType(); + } + + /** + * A fallback plain text can be provided, in case the engine does not support this class + * type, or even Markup altogether. + * @param plainText A string with the plain text. + * @return This instance. + */ + @SuppressWarnings("unchecked") + public C setPlainText(String plainText) { + mMarkup.setPlainText(plainText); + return (C) this; + } + + /** + * Returns the plain text (fallback) string. + * @return Plain text string or null if not set. + */ + public String getPlainText() { + return mMarkup.getPlainText(); + } + + /** + * Populates the plainText if not set and builds a Markup instance. + * @return The Markup object describing this instance. + */ + public Markup getMarkup() { + return new Markup(mMarkup); + } + + @SuppressWarnings("unchecked") + protected C setParameter(String key, String value) { + mMarkup.setParameter(key, value); + return (C) this; + } + + protected String getParameter(String key) { + return mMarkup.getParameter(key); + } + + @SuppressWarnings("unchecked") + protected C removeParameter(String key) { + mMarkup.removeParameter(key); + return (C) this; + } + + /** + * Returns a string representation of this instance, can be deserialized to an equal + * Utterance instance. + */ + public String toString() { + return mMarkup.toString(); + } + + /** + * Returns a generated plain text alternative for this instance if this instance isn't + * better representated by the list of it's children. + * @return Best effort plain text representation of this instance, can be null. + */ + public String generatePlainText() { + return null; + } + } + + public static abstract class AbstractTtsSemioticClass<C extends AbstractTtsSemioticClass<C>> + extends AbstractTts<C> { + // Keys. + private static final String KEY_GENDER = "gender"; + private static final String KEY_ANIMACY = "animacy"; + private static final String KEY_MULTIPLICITY = "multiplicity"; + private static final String KEY_CASE = "case"; + + protected AbstractTtsSemioticClass() { + super(); + } + + protected AbstractTtsSemioticClass(Markup markup) { + super(markup); + } + + @SuppressWarnings("unchecked") + public C setGender(int gender) { + if (gender < 0 || gender > 3) { + throw new IllegalArgumentException("Only four types of gender can be set: " + + "unknown, neutral, maculine and female."); + } + if (gender != GENDER_UNKNOWN) { + setParameter(KEY_GENDER, String.valueOf(gender)); + } else { + setParameter(KEY_GENDER, null); + } + return (C) this; + } + + public int getGender() { + String gender = mMarkup.getParameter(KEY_GENDER); + return gender != null ? Integer.valueOf(gender) : GENDER_UNKNOWN; + } + + @SuppressWarnings("unchecked") + public C setAnimacy(int animacy) { + if (animacy < 0 || animacy > 2) { + throw new IllegalArgumentException( + "Only two types of animacy can be set: unknown, animate and inanimate"); + } + if (animacy != ANIMACY_UNKNOWN) { + setParameter(KEY_ANIMACY, String.valueOf(animacy)); + } else { + setParameter(KEY_ANIMACY, null); + } + return (C) this; + } + + public int getAnimacy() { + String animacy = getParameter(KEY_ANIMACY); + return animacy != null ? Integer.valueOf(animacy) : ANIMACY_UNKNOWN; + } + + @SuppressWarnings("unchecked") + public C setMultiplicity(int multiplicity) { + if (multiplicity < 0 || multiplicity > 3) { + throw new IllegalArgumentException( + "Only four types of multiplicity can be set: unknown, single, dual and " + + "plural."); + } + if (multiplicity != MULTIPLICITY_UNKNOWN) { + setParameter(KEY_MULTIPLICITY, String.valueOf(multiplicity)); + } else { + setParameter(KEY_MULTIPLICITY, null); + } + return (C) this; + } + + public int getMultiplicity() { + String multiplicity = mMarkup.getParameter(KEY_MULTIPLICITY); + return multiplicity != null ? Integer.valueOf(multiplicity) : MULTIPLICITY_UNKNOWN; + } + + @SuppressWarnings("unchecked") + public C setCase(int grammaticalCase) { + if (grammaticalCase < 0 || grammaticalCase > 8) { + throw new IllegalArgumentException( + "Only nine types of grammatical case can be set."); + } + if (grammaticalCase != CASE_UNKNOWN) { + setParameter(KEY_CASE, String.valueOf(grammaticalCase)); + } else { + setParameter(KEY_CASE, null); + } + return (C) this; + } + + public int getCase() { + String grammaticalCase = mMarkup.getParameter(KEY_CASE); + return grammaticalCase != null ? Integer.valueOf(grammaticalCase) : CASE_UNKNOWN; + } + } + + /** + * Class that contains regular text, synthesis engine pronounces it using its regular pipeline. + * Parameters: + * <ul> + * <li>Text: the text to synthesize</li> + * </ul> + */ + public static class TtsText extends AbstractTtsSemioticClass<TtsText> { + + // The type of this node. + protected static final String TYPE_TEXT = "text"; + // The text parameter stores the text to be synthesized. + private static final String KEY_TEXT = "text"; + + /** + * Default constructor. + */ + public TtsText() { + mMarkup.setType(TYPE_TEXT); + } + + /** + * Constructor that sets the text to be synthesized. + * @param text The text to be synthesized. + */ + public TtsText(String text) { + this(); + setText(text); + } + + /** + * Constructs a TtsText with the values of the Markup, does not check if the given Markup is + * of the right type. + */ + private TtsText(Markup markup) { + super(markup); + } + + /** + * Sets the text to be synthesized. + * @return This instance. + */ + public TtsText setText(String text) { + setParameter(KEY_TEXT, text); + return this; + } + + /** + * Returns the text to be synthesized. + * @return This instance. + */ + public String getText() { + return getParameter(KEY_TEXT); + } + + /** + * Generates a best effort plain text, in this case simply the text. + */ + @Override + public String generatePlainText() { + return getText(); + } + } + + /** + * Contains a cardinal. + * Parameters: + * <ul> + * <li>integer: the integer to synthesize</li> + * </ul> + */ + public static class TtsCardinal extends AbstractTtsSemioticClass<TtsCardinal> { + + // The type of this node. + protected static final String TYPE_CARDINAL = "cardinal"; + // The parameter integer stores the integer to synthesize. + private static final String KEY_INTEGER = "integer"; + + /** + * Default constructor. + */ + public TtsCardinal() { + mMarkup.setType(TYPE_CARDINAL); + } + + /** + * Constructor that sets the integer to be synthesized. + */ + public TtsCardinal(int integer) { + this(); + setInteger(integer); + } + + /** + * Constructor that sets the integer to be synthesized. + */ + public TtsCardinal(String integer) { + this(); + setInteger(integer); + } + + /** + * Constructs a TtsText with the values of the Markup. + * Does not check if the given Markup is of the right type. + */ + private TtsCardinal(Markup markup) { + super(markup); + } + + /** + * Sets the integer. + * @return This instance. + */ + public TtsCardinal setInteger(int integer) { + return setInteger(String.valueOf(integer)); + } + + /** + * Sets the integer. + * @param integer A non-empty string of digits with an optional '-' in front. + * @return This instance. + */ + public TtsCardinal setInteger(String integer) { + if (!integer.matches("-?\\d+")) { + throw new IllegalArgumentException("Expected a cardinal: \"" + integer + "\""); + } + setParameter(KEY_INTEGER, integer); + return this; + } + + /** + * Returns the integer parameter. + */ + public String getInteger() { + return getParameter(KEY_INTEGER); + } + + /** + * Generates a best effort plain text, in this case simply the integer. + */ + @Override + public String generatePlainText() { + return getInteger(); + } + } + + /** + * Default constructor. + */ + public Utterance() {} + + /** + * Returns the plain text of a given Markup if it was set; if it's not set, recursively call the + * this same method on its children. + */ + private String constructPlainText(Markup m) { + StringBuilder plainText = new StringBuilder(); + if (m.getPlainText() != null) { + plainText.append(m.getPlainText()); + } else { + for (Markup nestedMarkup : m.getNestedMarkups()) { + String nestedPlainText = constructPlainText(nestedMarkup); + if (!nestedPlainText.isEmpty()) { + if (plainText.length() != 0) { + plainText.append(" "); + } + plainText.append(nestedPlainText); + } + } + } + return plainText.toString(); + } + + /** + * Creates a Markup instance with auto generated plain texts for the relevant nodes, in case the + * user has not provided one already. + * @return A Markup instance representing this utterance. + */ + public Markup createMarkup() { + Markup markup = new Markup(TYPE_UTTERANCE); + StringBuilder plainText = new StringBuilder(); + for (AbstractTts<? extends AbstractTts<?>> say : says) { + // Get a copy of this markup, and generate a plaintext for it if is not set. + Markup sayMarkup = say.getMarkup(); + if (sayMarkup.getPlainText() == null) { + sayMarkup.setPlainText(say.generatePlainText()); + } + if (plainText.length() != 0) { + plainText.append(" "); + } + plainText.append(constructPlainText(sayMarkup)); + markup.addNestedMarkup(sayMarkup); + } + if (mNoWarningOnFallback != null) { + markup.setParameter(KEY_NO_WARNING_ON_FALLBACK, + mNoWarningOnFallback ? "true" : "false"); + } + markup.setPlainText(plainText.toString()); + return markup; + } + + /** + * Appends an element to this Utterance instance. + * @return this instance + */ + public Utterance append(AbstractTts<? extends AbstractTts<?>> say) { + says.add(say); + return this; + } + + private Utterance append(Markup markup) { + if (markup.getType().equals(TtsText.TYPE_TEXT)) { + append(new TtsText(markup)); + } else if (markup.getType().equals(TtsCardinal.TYPE_CARDINAL)) { + append(new TtsCardinal(markup)); + } else { + // Unknown node, a class we don't know about. + if (markup.getPlainText() != null) { + append(new TtsText(markup.getPlainText())); + } else { + // No plainText specified; add its children + // seperately. In case of a new prosody node, + // we would still verbalize it correctly. + for (Markup nested : markup.getNestedMarkups()) { + append(nested); + } + } + } + return this; + } + + /** + * Returns a string representation of this Utterance instance. Can be deserialized back to an + * Utterance instance with utteranceFromString(). Can be used to store utterances to be used + * at a later time. + */ + public String toString() { + String out = "type: \"" + TYPE_UTTERANCE + "\""; + if (mNoWarningOnFallback != null) { + out += " no_warning_on_fallback: \"" + (mNoWarningOnFallback ? "true" : "false") + "\""; + } + for (AbstractTts<? extends AbstractTts<?>> say : says) { + out += " markup { " + say.getMarkup().toString() + " }"; + } + return out; + } + + /** + * Returns an Utterance instance from the string representation generated by toString(). + * @param string The string representation generated by toString(). + * @return The new Utterance instance. + * @throws {@link IllegalArgumentException} if the input cannot be correctly parsed. + */ + static public Utterance utteranceFromString(String string) throws IllegalArgumentException { + Utterance utterance = new Utterance(); + Markup markup = Markup.markupFromString(string); + if (!markup.getType().equals(TYPE_UTTERANCE)) { + throw new IllegalArgumentException("Top level markup should be of type \"" + + TYPE_UTTERANCE + "\", but was of type \"" + + markup.getType() + "\".") ; + } + for (Markup nestedMarkup : markup.getNestedMarkups()) { + utterance.append(nestedMarkup); + } + return utterance; + } + + /** + * Appends a new TtsText with the given text. + * @param text The text to synthesize. + * @return This instance. + */ + public Utterance append(String text) { + return append(new TtsText(text)); + } + + /** + * Appends a TtsCardinal representing the given number. + * @param integer The integer to synthesize. + * @return this + */ + public Utterance append(int integer) { + return append(new TtsCardinal(integer)); + } + + /** + * Returns the n'th element in this Utterance. + * @param i The index. + * @return The n'th element in this Utterance. + * @throws {@link IndexOutOfBoundsException} - if i < 0 || i >= size() + */ + public AbstractTts<? extends AbstractTts<?>> get(int i) { + return says.get(i); + } + + /** + * Returns the number of elements in this Utterance. + * @return The number of elements in this Utterance. + */ + public int size() { + return says.size(); + } + + @Override + public boolean equals(Object o) { + if ( this == o ) return true; + if ( !(o instanceof Utterance) ) return false; + Utterance utt = (Utterance) o; + + if (says.size() != utt.says.size()) { + return false; + } + + for (int i = 0; i < says.size(); i++) { + if (!says.get(i).getMarkup().equals(utt.says.get(i).getMarkup())) { + return false; + } + } + return true; + } + + /** + * Can be set to true or false, true indicating that the user provided only a string to the API, + * at which the system will not issue a warning if the synthesizer falls back onto the plain + * text when the synthesizer does not support Markup. + */ + public Utterance setNoWarningOnFallback(boolean noWarning) { + mNoWarningOnFallback = noWarning; + return this; + } +} |