/* * Copyright (C) 2013 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.android.talkback; import android.content.Context; import android.graphics.Typeface; import android.os.Build; import android.os.Bundle; import android.speech.tts.TextToSpeech; import android.text.Spannable; import android.text.TextUtils; import android.text.style.CharacterStyle; import android.text.style.StyleSpan; import android.text.style.URLSpan; import java.util.Set; /** * Utilities for generating {@link FeedbackItem}s populated with * {@link FeedbackFragment}s created according to processing rules. */ class FeedbackProcessingUtils { /** * Utterances must be no longer than maxUtteranceLength for the TTS to be * able to handle them properly. Similar limitation imposed by * {@link TextToSpeech#getMaxSpeechInputLength()} */ static int maxUtteranceLength; static { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { maxUtteranceLength = TextToSpeech.getMaxSpeechInputLength(); } else { maxUtteranceLength = 4000; } } /** The pitch scale factor value to use when announcing hyperlinks. */ private static final float PITCH_CHANGE_HYPERLINK = 0.95f; /** The pitch scale factor value to use when announcing bold text. */ private static final float PITCH_CHANGE_BOLD = 0.95f; /** The pitch scale factor value to use when announcing italic text. */ private static final float PITCH_CHANGE_ITALIC = 1.05f; /** * Produces a populated {@link FeedbackItem} based on rules defined within * this class. Currently splits utterances into reasonable chunks and adds * auditory and speech characteristics for formatting changes in processed * text. * * @param text The text to include * @param earcons The earcons to be played when this item is processed * @param haptics The haptic patterns to be produced when this item is * processed * @param flags The Flags defining the treatment of this item * @param speechParams The {@link SpeechController.SpeechParam} parameters to attribute to * the spoken feedback within each fragment in this item. * @param nonSpeechParams The {@link Utterance} parameters to attribute to * non-speech feedback for this item. * @return a populated {@link FeedbackItem} */ public static FeedbackItem generateFeedbackItemFromInput(Context context, CharSequence text, Set<Integer> earcons, Set<Integer> haptics, int flags, int utteranceGroup, Bundle speechParams, Bundle nonSpeechParams) { final FeedbackItem feedbackItem = new FeedbackItem(); final FeedbackFragment initialFragment = new FeedbackFragment( text, earcons, haptics, speechParams, nonSpeechParams); feedbackItem.addFragment(initialFragment); feedbackItem.addFlag(flags); feedbackItem.setUtteranceGroup(utteranceGroup); // Process the FeedbackItem addFormattingCharacteristics(feedbackItem); splitLongText(feedbackItem); return feedbackItem; } /** * Splits text contained within the {@link FeedbackItem}'s * {@link FeedbackFragment}s into fragments containing less than * {@link #maxUtteranceLength} characters. * * @param item The item containing fragments to split. */ // Visible for testing static void splitLongText(FeedbackItem item) { for (int i = 0; i < item.getFragments().size(); ++i) { final FeedbackFragment fragment = item.getFragments().get(i); final CharSequence fragmentText = fragment.getText(); if (TextUtils.isEmpty(fragmentText)) { continue; } if (fragmentText.length() >= maxUtteranceLength) { // If the text from an original fragment exceeds the allowable // fragment text length, start by removing the original fragment // from the item. item.removeFragment(fragment); // Split the fragment's text into multiple fragments that don't // exceed the limit and add new fragments at the appropriate // position in the item. final int end = fragmentText.length(); int start = 0; int splitFragments = 0; while (start < end) { final int fragmentEnd = start + maxUtteranceLength - 1; // TODO: We currently split only on spaces. // Find a better way to do this for languages that don't // use spaces. int splitLocation = TextUtils.lastIndexOf( fragmentText, ' ', start + 1, fragmentEnd); if (splitLocation < 0) { splitLocation = Math.min(fragmentEnd, end); } final CharSequence textSection = TextUtils.substring( fragmentText, start, splitLocation); final FeedbackFragment additionalFragment = new FeedbackFragment( textSection, fragment.getSpeechParams()); item.addFragmentAtPosition(additionalFragment, i + splitFragments); splitFragments++; start = splitLocation; } // Always replace the metadata from the original fragment on the // first fragment resulting from the split copyFragmentMetadata(fragment, item.getFragments().get(i)); } } } /** * Splits and adds feedback to {@link FeedbackItem}s for spannable text * contained within this {@link FeedbackItem} * * @param item The item to process for formatted text. */ static void addFormattingCharacteristics(FeedbackItem item) { for (int i = 0; i < item.getFragments().size(); ++i) { final FeedbackFragment fragment = item.getFragments().get(i); final CharSequence fragmentText = fragment.getText(); if (TextUtils.isEmpty(fragmentText) || !(fragmentText instanceof Spannable)) { continue; } Spannable spannable = (Spannable) fragmentText; int len = spannable.length(); int next; for (int begin = 0; begin < len; begin = next) { // CharacterStyle is a superclass of both URLSpan and StyleSpan; we want to split by // only URLSpan/StyleSpan, but it is OK if we request any CharacterStyle in the list // of spans since we ignore the ones that are not URLSpan/StyleSpan. next = nextSpanTransition(spannable, begin, len, URLSpan.class, StyleSpan.class); CharacterStyle[] spans = spannable.getSpans(begin, next, CharacterStyle.class); // Since we add earcons and change pitch for URLs and styling, we can only handle // one type of span per block. URLs seem more important, so they get priority. CharacterStyle chosenSpan = null; for (CharacterStyle span : spans) { if (span instanceof URLSpan) { chosenSpan = span; } else if (span instanceof StyleSpan && !(chosenSpan instanceof URLSpan)) { chosenSpan = span; } } final FeedbackFragment newFragment; if (begin == 0) { // This is the first new fragment, so we should reuse the old fragment. // That way, we'll keep the existing haptic/earcon feedback at the beginning! newFragment = fragment; newFragment.setText(spannable.subSequence(0, next)); } else { // Otherwise, add after the last fragment processed/added. newFragment = new FeedbackFragment(spannable.subSequence(begin, next), null); ++i; item.addFragmentAtPosition(newFragment, i); } if (chosenSpan instanceof URLSpan) { handleUrlSpan(newFragment); } else if (chosenSpan instanceof StyleSpan) { handleStyleSpan(newFragment, (StyleSpan) chosenSpan); } } } } private static int nextSpanTransition(Spannable spannable, int start, int limit, Class... types) { int next = limit; for (Class type : types) { int currentNext = spannable.nextSpanTransition(start, limit, type); if (currentNext < next) { next = currentNext; } } return next; } /** * Handles the splitting of {@link StyleSpan}s into multiple * {@link FeedbackFragment}s. * * @param fragment The fragment containing the spannable text to process. */ private static void handleUrlSpan(FeedbackFragment fragment) { final Bundle speechParams = new Bundle(Bundle.EMPTY); speechParams.putFloat(SpeechController.SpeechParam.PITCH, PITCH_CHANGE_HYPERLINK); fragment.setSpeechParams(speechParams); fragment.addEarcon(R.raw.hyperlink); } /** * Handles the splitting of {@link URLSpan}s into multiple * {@link FeedbackFragment}s. * * @param fragment The fragment containing the spannable text to process. * @param span The individual {@link StyleSpan} that represents the span */ private static void handleStyleSpan(FeedbackFragment fragment, StyleSpan span) { final int style = span.getStyle(); final int earconId; final float voicePitch; switch (style) { case Typeface.BOLD: case Typeface.BOLD_ITALIC: voicePitch = PITCH_CHANGE_BOLD; earconId = R.raw.bold; break; case Typeface.ITALIC: voicePitch = PITCH_CHANGE_ITALIC; earconId = R.raw.italic; break; default: return; } final Bundle speechParams = new Bundle(Bundle.EMPTY); speechParams.putFloat(SpeechController.SpeechParam.PITCH, voicePitch); fragment.setSpeechParams(speechParams); fragment.addEarcon(earconId); } private static void copyFragmentMetadata(FeedbackFragment from, FeedbackFragment to) { to.setSpeechParams(from.getSpeechParams()); to.setNonSpeechParams(from.getNonSpeechParams()); for (int id : from.getEarcons()) { to.addEarcon(id); } for (int id : from.getHaptics()) { to.addHaptic(id); } } private static void clearFragmentMetadata(FeedbackFragment fragment) { fragment.setSpeechParams(new Bundle()); fragment.setNonSpeechParams(new Bundle()); fragment.clearAllEarcons(); fragment.clearAllHaptics(); } }