суббота, 23 июля 2011 г.

Синтез речи в Android-приложении

Не так давно пришлось прикручивать к нашему приложению озвучку с помощью Text-to-Speech (TTS). Об этом-то я и хочу сегодня рассказать.

Quick Start

TTS можно использовать двумя способами. Во-первых, можно завязываться на конкретный движок, покупать библиотеку и работать через неё. Про этот вариант ничего не могу сказать, знаю только теоретически. Второй, общеизвестный вариант — использовать стандартное API. Голоса в этом случае являются просто приложениями, установленными в системе.

Вообще-то заставить приложение говорить не так сложно, и мануалов по этому поводу полно. Но для полноты картины приведу начальные сведения.

Начиная с версии 1.6 в SDK есть стандартный класс TextToSpeech.

Подключение в приложение

Простейшая схема такова:

MainActivity.java

public class StartActivity extends Activity {
    private static final String enginePackageName = "com.svox.pico";
    
    private static final String SAMPLE_TEXT = "Synthesizes speech from text for immediate playback or to create a sound file.";
    TextToSpeech tts;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        setContentView(R.layout.main);
        
        tts = new TextToSpeech(this, new OnInitListener() {
            @Override
            public void onInit(int status) {
                if (status == TextToSpeech.SUCCESS) {
                    tts.setEngineByPackageName(enginePackageName);
                    tts.setLanguage(Locale.UK);

                    speak();                
                }
            }
        });
    }

    private void speak() {
        tts.speak(SAMPLE_TEXT, TextToSpeech.QUEUE_FLUSH, null);
    }
    
    @Override
    protected void onDestroy() {
        super.onDestroy();
        tts.shutdown();
    }
}

Все вроде понятно. Создали экземпляр TextToSpeech, инициализировали в специальном листенере (задавать голос мы можем только в onInit), и с тех пор можем синтезировать и проигрывать речь с помощью метода speak. Обращу внимание, что это только схема, более приближенное к реальности приложение можно найти в примере к статье.

Метод speak

Рассмотрим подробнее сигнатуру метода speak:

speak(String text, int queueMode, HashMap params)

text
Текст, который нужно прочитать
queueMode
  • TextToSpeech.QUEUE_FLUSH, если хочется, чтобы предыдущая фраза прерывалась и сразу начиналась следующая
  • TextToSpeech.QUEUE_ADD, если хочется, чтобы предыдущая фраза договорилась до конца только после этого началась следующая
params
Массив дополнительных параметров. Возможные параметры:
  • TextToSpeech.Engine.KEY_PARAM_STREAM — поток, в котором будет воспроизводиться звук.
  • TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID — идентификатор фразы. Пригодится, если хочется обрабатывать событие окончания говорения, и при этом не запутаться в произносимых фразах.

Другие полезные методы

playSilence(long durationInMs, int queueMode, HashMap params)
Проигрывает тишину в течение заданного времени. Параметры те же, что у speak.
stop()
Останавливает воспроизведение
synthesizeToFile(String text, HashMap params, String filename)
Записывает синтезированную речь в файл. Параметры те же, что у speak.
addSpeech(String text, String filename), addSpeech(String text, String packagename, int resourceId)
Задает маппинг между фразой и существующим файлом/ресурсом. Если такой маппинг задан, то вместо синтезированной речи метод speak будет воспроизводить данный файл.
setOnUtteranceCompletedListener(TextToSpeech.OnUtteranceCompletedListener listener)
Задает слушателя для события окончания фразы.
areDefaultsEnforced()
Установлена ли в настройках TTS галочка «Мои настройки». О ней будет подробнее.
setPitch(float pitch)
Задает тембр голоса. 1 — обычное значение, чем меньше значение, тем ниже голос.
setSpeechRate(float speechRate)
Задает скорость речи. 1 — обычное значение, чем меньше значение, тем медленнее говорим.

TTS engines

Вкратце расскажу об известных TTS-движках. Как уже говорилось ранее, голоса — это просто сторонние приложения. Посмотрим, что у нас есть под Android.

Pico
Стандартный TTS-движок, знает 5 языков, поставляется бесплатно. Говорит неплохо, но русского не знает.
eSpeak
Свободный TTS-движок. Знает очень много языков. По-русски тоже говорит, но отвратительно.
SVOX
Довольно известный движок. Под Android распространяется следующим образом. Есть бесплатная программа-оболочка и платные голоса, которыми можно управлять из этой оболочки. Голосов очень много. Достаточно неплохо говорит по-русски, хотя есть проблемы с ударениями. В общем-то голос SVOX оказался единственным вариантом для русской озвучки приложения.
Loquendo
Также известный и качественный движок. К сожалению, в Android представлен мало. Для английского языка есть голос Susan, а вот для русского языка приложения нет, хотя вообще-то Loquendo говорить по-русски умеет.

А теперь немного о сложностях.

Проверка наличия голосовых данных

Pico TTS поставляется по умолчанию с системой. Но на некоторых моделях телефонов не установлены голосовые пакеты. Внешне это проявляется, например, в том, что в системных настройках синтеза речи всё задизаблено и предлагается скачать и установить некие ресурсы:

Отсутствуют голосовые данные

В официальном мануале описан способ обработки этой ситуации.

CheckVoiceActivity.java

public class CheckVoiceActivity extends Activity {
    TextToSpeech tts;

    private static final int REQUEST_CODE = 150;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        setContentView(R.layout.main);        

    }

    public void onPrepareSpeech(View view) {
        Intent checkIntent = new Intent();
        checkIntent.setAction(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
        checkIntent.setPackage(Consts.ENGINE);
        
        startActivityForResult(checkIntent, REQUEST_CODE);
    }

    protected void onActivityResult(int requestCode, int resultCode, Intent data)
    {
        if (requestCode == REQUEST_CODE)
        {
            if (resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS)
            {
                // Голосовые данные установлены, можно создавать экземпляр TextToSpeech
                ...
            }
            else
            {
                // голосовые данные отсутствуют, предлагаем установить
                Intent installIntent = new Intent();
                installIntent.setAction(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA);
                installIntent.setPackage(Consts.ENGINE);
                startActivity(installIntent);
            }
        }
    }
    ...
}

Особенности работы под Android 2.1

Наше приложение должно было разговаривать не абы каким голосом, а исключительно красивым. Соответственно, была задача выбрать нужный нам TTS-движок из всех установленных у пользователя. В Android 2.2 у класса TextToSpeech есть метод setEngineByPackageName, но что делать в 2.1, где такого метода нет?

Существует известный обход этой проблемы, с использованием дополнительной программы и дополнительной библиотеки. В плане юзабилити, конечно, не ахти, ведь придется заставлять пользователя ставить какой-то сторонний софт. Зато работает. Итак:

  • Устанавливаем на телефон приложение Text-to-speech Extended (ссылка на маркет: market://details?id=com.google.tts)
  • Подключаем к нашему приложению библиотеку от eyes-free.
  • Вместо привычного TextToSpeech используем класс TextToSpeechBeta из этой библиотеки

Имеет смысл написать класс-оболочку такого примерно вида:

TextToSpeechWrapper

package com.demos.tts;

import java.util.HashMap;
import java.util.Locale;

import com.google.tts.TextToSpeechBeta;

import android.content.Context;
import android.os.Build;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.OnInitListener;
import android.speech.tts.TextToSpeech.OnUtteranceCompletedListener;

/**
 * Оболочка над TTS/TTSE
 * 
 * @author darja.ryazhskikh
 * 
 */
public class TextToSpeechWrapper {
    private TextToSpeech tts;
    private TextToSpeechBeta ttse;

    private Context context;

    public TextToSpeechWrapper(Context context) {
    this.context = context;
    }

    /**
     * Создаем стандартный TextToSpeech в случае версии Android от 2.2, и объект
     * TextToSpeechBeta для 2.1
     * 
     * @param context
     * @param listener
     */
    public void init(final OnInitListener listener) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) {
        tts = new TextToSpeech(context, listener);
        ttse = null;
    } else {
        if (TextToSpeechBeta.isInstalled(context)) {
     ttse = new TextToSpeechBeta(context,
      new TextToSpeechBeta.OnInitListener() {
          @Override
          public void onInit(int status, int version) {
       listener.onInit(status);
          }
      });
        } else {
     ttse = null;
        }
        tts = null;
    }
    }

    /**
     * Проверяет, установлена ли в настройках TTS галочка
     * "Always use my settings"
     * 
     * @return
     */
    public Boolean areDefaultsEnforced() {
    if (tts != null)
        return tts.areDefaultsEnforced();
    else if (ttse != null)
        return ttse.areDefaultsEnforcedExtended();
    else
        return null;
    }

    public boolean setEngineByPackageName(String engine) {
    boolean success = false;
    try {
        if (tts != null)
     success = tts.setEngineByPackageName(engine) == TextToSpeech.SUCCESS;
        else if (ttse != null)
     success = ttse.setEngineByPackageNameExtended(engine) == TextToSpeechBeta.SUCCESS;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return success;
    }

    public void speak(String text, HashMap<String, String> params) {
    if (tts != null)
        tts.speak(text, TextToSpeech.QUEUE_FLUSH, params);
    else if (ttse != null)
        ttse.speak(text, TextToSpeech.QUEUE_FLUSH, params);
    }

    public void stop() {
    if (tts != null)
        tts.stop();
    else if (ttse != null)
        ttse.stop();
    }

    public boolean isLanguageAvailable(Locale loc) {
    if (tts != null) {
        int result = tts.isLanguageAvailable(loc);
        return result >= TextToSpeech.LANG_AVAILABLE;
    } else if (ttse != null)
        return ttse.isLanguageAvailable(loc) >= TextToSpeechBeta.LANG_AVAILABLE;

    return false;
    }

    public boolean setLanguage(Locale loc) {
    if (tts != null)
        return tts.setLanguage(loc) >= TextToSpeech.LANG_AVAILABLE;
    else if (ttse != null)
        return ttse.setLanguage(loc) >= TextToSpeechBeta.LANG_AVAILABLE;
    return false;
    }

    /**
     * Задает слушателя на окончание фразы
     * 
     * @param listener
     */
    public void setOnUtteranceCompletedListener(
        final OnUtteranceCompletedListener listener) {
    if (tts != null)
        tts.setOnUtteranceCompletedListener(listener);
    else if (ttse != null)
        ttse.setOnUtteranceCompletedListener(new TextToSpeechBeta.OnUtteranceCompletedListener() {
     @Override
     public void onUtteranceCompleted(String utteranceId) {
         listener.onUtteranceCompleted(utteranceId);
     }
        });
    }

    public void shutdown() {
    if (tts != null)
        tts.shutdown();
    if (ttse != null)
        ttse.shutdown();
    }
}

Конкретная реализация может быть и другой.

Конфигурируем TTS

Нам нужно сконфигурировать TTS определенным голосом. Голос, в свою очередь, определяется следующими параметрами:

  • Engine — задается функцией setEngineByPackageName.
  • Locale — задается функцией setLanguage.

Вариант 1, легкий, но редкий

Так работает Loquendo. Пишем:

tts.setEngineByPackageName("com.loquendo.tts.susan");

И всё начинает работать.

Вариант 2, сложный и частый

Так работают Pico и SVOX. У них есть оболочка (engine) и подключаемые модули (голоса). Рассмотрим на примере Pico

tts.setEngineByPackageName("com.svox.pico");
tts.setLanguage(Locale.US);

Тоже вроде все работает. Проблемы начинаются, когда у одной локали оказывается несколько голосов. Такое имеет место для SVOX. У одного языка может быть мужской, женский и детский голос. Это разные приложения, у них разные названия пакетов, но с точки зрения TTS все это одно и то же.

Если установлено несколько голосов для одной локали, выбран будет тот, который указан в настройках SVOX как дефолтный. Однако, мы это никак отследить не можем. Печально.

Общие проблемы для обоих вариантов

TTS-движок задизаблен в настройках TextToSpeech

Вот так:

SVOX disabled

У меня так и не получилось отловить эту ситуацию. По идее, setEngineByPackageName должен бы вернуть ERROR, и мы бы догадались, что что-то не так. Но он отрабатывает на ура, и приложение разговаривает, чем попало.

Галочка "Использовать мои настройки"

Это тоже достаточно вредная штука, и её нужно учитывать. Дело в том, что пользователь может выставить собственные настройки TTS и эту галочку.

Мои настройки

И тогда вся ваша конфигурация не будет применяться. Отслеживать состояние этой настройки можно с помощью метода areDefaultsEnforced (в Android 2.2 и выше. Если версия меньше, нужен TTSE и метод areDefaultsEnforcedExtended)

Заключение

Собственно, вот и все, что накопилось за те две недели, что я занимаюсь озвучкой приложения. Субъективное ощущение от этого API — сыровато. Не хватает доступа ко всем настройкам TTS в системе. Для пользователя они слишком сложные и неочевидные ("Мои настройки" — яркий пример). Разнобой в опциях различных TTS-движков также печалит. В общем, использовать TTS не так сложно, а вот обрабатывать различные его состояния — целое дело.

Ссылки

Пример

Исходники к статье прилагаются. Там рассмотрены следующие ситуации:

  • Простая инициализация TTS
  • Проверка голосовых данных Pico
  • Использование TextToSpeechBeta

7 комментариев:

Dima L. комментирует...

Делал такую штуку в своем проекте, тоже пришел к SVOX как к единственному движку на андроиде, который более или менее нормально говорит по-русски. Есть еще такой ньюанс, что на слабых девайсах проговаривание речи SVOX'ом несколько замедляет устройство, что не критично для чтения СМСэок, но может неплохо тормозить анимацию на экране.

darja комментирует...

У нас как раз приложение всё из себя с анимациями. Тормозов особых замечено не было, но все равно перенесли синтез речи в отдельный поток.

Dima L. комментирует...

А на каком девайсе тестировали?
Попробуйте ради интереса на каком-нибудь HTC Hero или WildFire. У меня на Wildfire SVOX "съедал" плавность анимации.

Кстати в отдельный поток выносить его не обязательно, оно само создает отдельный поток во время синтеза речи, т.е. можно спокойно из потока UI выполнять.

darja комментирует...

Самое слабое устройство было LG GT540.

Dima L. комментирует...

Добавлю еще свои 5 копеек в мутный гугловый TTS API :)

Если вам вдруг захочется когда-нибудь прикрутить к TTS Listener, который срабатывает на окончание проговаривания речи setOnUtteranceCompletedListener(), то вас ждет сюрприз :)

Эта штука работает только если листенер инициализировать из onInit и только если вы в вызов метода speak передаете параметры (HashMap) среди которых обязательно есть TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID.

Естественно, эти "необязательные" условия не упоминаются. Я убил 2 дня жизни чтобы дойти до этого решения которое делается за 15 мин.
Мож кому-то пригодиться ;)

Misty Del комментирует...

Доброго времени суток))) подскажите как TTS удалить... установила по незнанию и сейчас мучаюсь((( Спасибо

Светлана Черненко комментирует...

Дорогая Даша! Помоги! Жить не могу без говорилки! Галокси S c андроидом 2.1, есть права рут, есть TTS, SVOX, Катя, ни как не могу добавить TTS в систему. Об оплате договоримся. ChernenkoSwetlana@gmail.com