QnA: Text-To-Speech

Автор: Алексей Кирюшкин
The RSDN Group

Источник: RSDN Magazine #5-2004
Опубликовано: 27.12.2004
Версия текста: 1.1
Как прикрутить к моей программе преобразование из текста в голос?
А как получить список установленных голосов?
А если нужно не проговаривать, а создавать из текста аудиофайл?

Демонстрационные проекты

Как прикрутить к моей программе преобразование из текста в голос?

Наиболее просто это сделать с использованием Microsoft Speech API v5.1.

Speech API 5.1

Для работы вам понадобится Speech SDK 5.1 for Windows® applications. Скачиваем, устанавливаем, добавляем пути к h- и lib-файлам SDK в настройки студии.

Подключаем к проекту необходимые файлы (здесь и далее под проектом подразумевается WTL-проект, созданный в Visual Studio 7.1):

        #include <sapi.h>
#pragma warning(disable: 4267)
#include <sphelper.h>
#pragma warning(default: 4267)

я предпочитаю работать с COM-объектами через _com_ptr_t, поэтому добавляю еще макрос, определяющий обертку для интерфейса ISpVoice:

        #include <comdef.h>
_COM_SMARTPTR_TYPEDEF(ISpVoice, __uuidof(ISpVoice));

соответственно, код для генерации голоса по тексту будет выглядеть так:

ПРЕДУПРЕЖДЕНИЕ

В прилагаемых демонстрационных проектах реальная обработка ошибок заменена на ASSERT-ы, поэтому рекомендуется тестировать Debug-версии, т.к. работа Release-версий с отключенной индикацией ошибок может вызвать недоумение.

LRESULT CMainDlg::OnBnClickedBtnSpeech(WORD /*wNotifyCode*/, WORD /*wID*/, HWND /*hWndCtl*/, BOOL& /*bHandled*/)
{
  if ( DoDataExchange( TRUE ) )
  {
    ISpVoicePtr spVoice;

    // "поднимаем" компонент
    HRESULT hr = spVoice.CreateInstance( CLSID_SpVoice );
    _ASSERTE( hr == S_OK );

    if ( SUCCEEDED(hr) ) 
    {
      // говорим// CString _text - собственно текст из поля ввода
      hr = spVoice->Speak( CA2W( _text ), SPF_DEFAULT, NULL );
      _ASSERTE( hr == S_OK );
    }
  }

  return 0;
}

Ложкой дегтя в этой бочке меда является то, что для Speech API 5.1 нет бесплатных или хотя бы "условно бесплатных" (см. ниже) движков для синтеза русской речи. Зато такие движки, даже в некотором ассортименте, есть для Speech API v4.0.

Speech API 4.0

Опять же нужно будет скачать и установить Speech SDK 4.0. Условно-бесплатный движок (Lernout & Hauspie TTS3000 TTS Engine) для синтеза русской речи можно взять со страницы Microsoft Agent download page for end-users. Условно-бесплатный, потому что (цитата):

ПРЕДУПРЕЖДЕНИЕ

Further note, that these text-to-speech engines are licensed only for use in Microsoft Agent enabled applications and Web pages with a visibly displayed Microsoft Agent character.

Добавляем к проекту файлы, необходимые для работы со Speech API 4.0:

        #include <initguid.h>
#include <speech.h> 

#include <ComDef.h>
_COM_SMARTPTR_TYPEDEF(ITTSEnum, IID_ITTSEnum);
_COM_SMARTPTR_TYPEDEF(ITTSCentral, IID_ITTSCentral);
_COM_SMARTPTR_TYPEDEF(IAudioMultiMediaDevice, IID_IAudioMultiMediaDevice);
_COM_SMARTPTR_TYPEDEF(IAudio, IID_IAudio);

Процесс преобразования текста в голос, в отличии от рассмотренного выше примера будет асинхронным, поэтому нам понадобятся несколько "долгоживущих" переменных. Можно сделать их членами класса тестового диалога:

        // собственно интерфейс к движку TTS
  ITTSCentralPtr _spTTSCentral;
  // объекты для получения уведомлений о ходе процесса преобразования
  CTestBufNotify  _TestBufNotify;
  CTestNotify  _TestNotify;
  // cookies, получаемая при регистрации notify-интерфейсов, будет нужна при разрегистрации
  DWORD _dwRegKey;

  // интерфейс для выбора аудио-устройства
  IAudioMultiMediaDevicePtr _spIAudioMultiMediaDevice;

и собственно запуск процесса преобразования:

LRESULT CMainDlg::OnBnClickedBtnSpeech(WORD /*wNotifyCode*/, WORD /*wID*/, HWND /*hWndCtl*/, BOOL& /*bHandled*/)
{
  if ( DoDataExchange( TRUE ) && _text.GetLength() )
  {
    // прежде чем регистрироваться с новыми параметрами, надо разрегистрироваться
    UnregTTSCentral();

    // задаем аудио-устройство для вывода звукаif( !InitMMAudio() )
      return 0;

    // поднимаем компонент - enumerator
    ITTSEnumPtr spTTSEnum;
    HRESULT hr = spTTSEnum.CreateInstance( CLSID_TTSEnumerator );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // задаем используемый голос (его GUID)// _VoicesCombo - combobox для выбора голосовint i = _VoicesCombo.GetCurSel();
    // _GUIDVoices[ i ] - GUID текущего голоса, сохраненный при заполнении ComboBox-а
    hr = spTTSEnum->Select( _GUIDVoices[ i ], &_spTTSCentral, _spIAudioMultiMediaDevice );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // регистрируемся в движке
    hr = _spTTSCentral->Register( &_TestNotify, IID_ITTSNotifySink, &_dwRegKey );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // объект для передачи текста в функцию TextData, он может быть локкальным, т.к.// TextData скопирует эти данные перед использованием
    SDATA TextToSpeech; 
    TextToSpeech.dwSize = _text.GetLength() + 1;
    TextToSpeech.pData = _text.GetBuffer(0);

    // Функция TextData асинхронная, для воспроизведения создаст поток и тут же вернет управление// по окончании преобразования _TestNotify вызовет OnAudioStop();
    hr = _spTTSCentral->TextData( CHARSET_TEXT, 0, TextToSpeech, &_TestBufNotify, IID_ITTSBufNotifySink );
    _text.ReleaseBuffer();
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // запрещаем кнопку запуска процесса преобразования
    _BtnSpeech.EnableWindow( FALSE );
  }

  return 0;
}

void CMainDlg::OnAudioStop()
{
  // разрешаем кнопку запуска процесса преобразования
  _BtnSpeech.EnableWindow( TRUE );
}

Функции UnregTTSCentral() и InitMMAudio() выглядят так:

        void CMainDlg::UnregTTSCentral()
{
  if ( _spTTSCentral )
  {
    HRESULT hr = _spTTSCentral->UnRegister( _dwRegKey );
    _spTTSCentral = NULL;
    _ASSERTE( hr == S_OK );
  }
}

BOOL CMainDlg::InitMMAudio()
{
  HRESULT hr;

  if ( _spIAudioMultiMediaDevice )
  {
    hr = ( ( IAudioPtr ) _spIAudioMultiMediaDevice ) ->Flush();
    _spIAudioMultiMediaDevice = NULL;
    _ASSERTE( hr == S_OK );
  }

  hr = _spIAudioMultiMediaDevice.CreateInstance( CLSID_MMAudioDest );
  _ASSERTE( hr == S_OK );

  if ( SUCCEEDED( hr ) )
  {
    hr = _spIAudioMultiMediaDevice->DeviceNumSet( WAVE_MAPPER ); // устройство по умолчанию
    _ASSERTE( hr == S_OK );

    if ( SUCCEEDED( hr ) )
      return TRUE;
  }
  elsereturn FALSE;

  return TRUE;
}

Реализация объектов CTestBufNotify и CTestNotify, используемых для уведомления о ходе процесса преобразования, в частности о его окончании, взята из примеров Speech 4.0 SDK.

А как получить список установленных голосов?

Speech API 4.0

Для только что рассмотренного примера использования SAPI 4 это происходит при заполнении комбобокса со списком голосов:

BOOL CMainDlg::FillComboVoices()
{
  ITTSEnumPtr spTTSEnum;
  TTSMODEINFO TTSModeInfo;
  DWORD dwNumTimes = 0;

  HRESULT hr;
  // поднимаем компонент - enumerator
  hr = spTTSEnum.CreateInstance( CLSID_TTSEnumerator );
  _ASSERTE( hr == S_OK );

  if ( FAILED( hr ) )
    return FALSE;

  // получаем общее количество голосов, и// если они есть, информацию по первому голосу
  hr = spTTSEnum->Next ( 1, &TTSModeInfo, &dwNumTimes );
  _ASSERTE( SUCCEEDED( hr ) );

  if ( FAILED( hr ) )
    return FALSE;

  if ( dwNumTimes == 0 )
  {
    ::MessageBox( m_hWnd, "Не обнаружено ни одного голоса", "FillComboVoices()", MB_OK | MB_ICONSTOP );
    return FALSE;
  }

  // очищаем vector для GUID-ов
  _GUIDVoices.clear();
  // и combobox для текстовых названий голосов
  _VoicesCombo.ResetContent();

  while ( dwNumTimes )
  {
    // запоминаем текстовое названиеif ( TTSModeInfo.dwFeatures & TTSFEATURE_ANYWORD )
      _VoicesCombo.AddString( TTSModeInfo.szModeName );
    else
    {
      CString sz;
      sz = TTSModeInfo.szModeName;
      sz += " (Может говорить только определеные слова!)";
      _VoicesCombo.AddString( sz );
    }

    // и GUID
    _GUIDVoices.push_back( TTSModeInfo.gModeID );

    // и следующий
    hr = spTTSEnum->Next( 1, &TTSModeInfo, &dwNumTimes );
    _ASSERTE( SUCCEEDED( hr ) );

    if ( FAILED( hr ) )
      return FALSE;
  }

  return TRUE;
}


Speech API 5.1

Для SAPI 5.1 все сводится к вызову функции из SDK:

        #include <spuihelp.h>

// заполнение combobox-а голосами
BOOL CMainDlg::FillComboVoices()
{
  //   CComboBox _VoicesCombo = GetDlgItem( IDC_COMBO_VOICES );
  HRESULT hr = SpInitTokenComboBox( _VoicesCombo, SPCAT_VOICES );
  _ASSERTE( hr == S_OK );

  if( SUCCEEDED( hr ) )
    return TRUE;
  elsereturn FALSE;
}


а пример преобразования с применением выбранного голоса станет таким:

LRESULT CMainDlg::OnBnClickedBtnSpeech(WORD /*wNotifyCode*/, WORD /*wID*/, HWND /*hWndCtl*/, BOOL& /*bHandled*/)
{
  if ( DoDataExchange( TRUE ) )
  {
    ISpVoicePtr spVoice;

    // "поднимаем" компонент
    HRESULT hr = spVoice.CreateInstance( CLSID_SpVoice );
    _ASSERTE( hr == S_OK );

    if ( SUCCEEDED(hr) ) 
    {
      // получаем информацию о новом выбранном голосе
      ISpObjectToken* pToken = SpGetCurSelComboBoxToken( _VoicesCombo );

      // текущий голос
      ISpObjectTokenPtr pOldToken;
      hr = spVoice->GetVoice( &pOldToken );
      _ASSERTE( hr == S_OK );

      if (SUCCEEDED(hr))
      {
        // устананавливать новый голос имеет смысл только// если он действительно поменялсяif (pOldToken != pToken)
        {
          hr = spVoice->SetVoice( pToken );
          _ASSERTE( hr == S_OK );
        }
      }

      // говорим// CString _text - собственно текст из поля ввода
      hr = spVoice->Speak( CA2W( _text ), SPF_DEFAULT, NULL );
      _ASSERTE( hr == S_OK );
    }
  }

  return 0;
}

А если нужно не проговаривать, а создавать из текста аудиофайл?

Speech API 4.0

Процесс аналогичен простому воспроизведению, только при регистрации в TTS движке используется не IAudioMultiMediaDevice, а предварительно сконфигурированный указатель на интефейс IAudioFile:

        // имя аудиофайла
  CString _AudioFile;
  // формат создаваемого аудиофайла, инициализируется в конструкторе CMainDlg
  WAVEFORMATFULL _fmt;
  // интерфейс для задания формата аудиофайла
  IAudioFilePtr _spAudioFile;

задаем параметры:

BOOL CMainDlg::InitFileAudio()
{
  if ( DoDataExchange( DDX_SAVE ) )
  {
    HRESULT hr;

    // если уже используется, освобождаемif ( _spAudioFile )
    {
      hr = _spAudioFile->Flush();
      _spAudioFile = NULL;
      _ASSERTE( hr == S_OK );
    }

    // поднимаем компонент
    hr = _spAudioFile.CreateInstance( CLSID_AudioDestFile );
    _ASSERTE( hr == S_OK );

    if ( FAILED( hr ) )
      return FALSE;

    // устанавливаем скорость преобразования// 0x100 означает реальную скорость - 1сек речи преобразуется за 1сек// 0x200 - 1сек речи записывается за 0,5 сек и т.д.
    hr = _spAudioFile->RealTimeSet( 0x100 << _RateIdx );
    _ASSERTE( hr == S_OK );

    if ( FAILED( hr ) )
      return FALSE;

    // устанавливаем формат записываемого файла// ВНИМАНИЕ: Используемый движок не обязательно поддерживает все возможные форматы// причем выяснится это не здесь, а при spTTSEnum->Select()
    SDATA WFEX;
    WFEX.pData = &_fmt; // предварительно сформированная структура с форматом аудиофайла
    WFEX.dwSize = sizeof(WAVEFORMATEX) + _fmt.cbSize;
    hr = ( ( IAudioPtr ) _spAudioFile ) ->WaveFormatSet( WFEX );
    _ASSERTE( hr == S_OK );

    if ( FAILED( hr ) )
      return FALSE;

    return TRUE;
  }
  elsereturn FALSE;

  return TRUE;
}

и собственно преобразование текста в аудиофайл:

LRESULT CMainDlg::OnBnClickedBtnFile(WORD /*wNotifyCode*/, WORD /*wID*/, HWND /*hWndCtl*/, BOOL& /*bHandled*/)
{
  if ( DoDataExchange( DDX_SAVE ) && _text.GetLength() )
  {
    // прежде чем регистрироваться с новыми параметрами, надо разрегистрироваться
    UnregTTSCentral();

    // заново задаем формат аудиофайлаif ( !InitFileAudio() )
      return 0;

    // если есть такой файл, удаляемif ( _access( _AudioFile, 0 ) == 0 ) 
      ::DeleteFile( _AudioFile );

    _ASSERTE( _access( _AudioFile, 0 ) == -1 );

    HRESULT hr;
    ITTSEnumPtr spTTSEnum;

    // поднимаем компонент - enumerator
    hr = spTTSEnum.CreateInstance( CLSID_TTSEnumerator );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // устанавливаем имя аудиофайла
    hr = _spAudioFile->Set( CA2W( _AudioFile ), 1 );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // задаем используемый голос (его GUID) и "приемник" голоса - аудиофайл// _VoicesCombo - combobox для выбора голосовint i = _VoicesCombo.GetCurSel();
    // _GUIDVoices[ i ] - GUID текущего голоса, сохраненный при заполнении ComboBox-а
    hr = spTTSEnum->Select( _GUIDVoices[ i ], &_spTTSCentral, _spAudioFile );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // регистрируемся в движке
    hr = _spTTSCentral->Register( &_TestNotify, IID_ITTSNotifySink, &_dwRegKey );
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // объект для передачи текста в функцию TextData, он может быть локальным, т.к.// TextData скопирует эти данные перед использованием
    SDATA TextToSpeech; 
    TextToSpeech.dwSize = _text.GetLength() + 1;
    TextToSpeech.pData = _text.GetBuffer(0);

    // Функция TextData асинхронная, для воспроизведения создаст поток и тут же вернет управление// по окончании преобразования _TestNotify вызовет OnAudioStop();
    hr = _spTTSCentral->TextData( CHARSET_TEXT, 0, TextToSpeech, &_TestBufNotify, IID_ITTSBufNotifySink );
    _text.ReleaseBuffer();
    _ASSERTE( hr == S_OK );
    if ( FAILED( hr ) )
      return 0;

    // запрещаем кнопку запуска процесса преобразования
    _BtnSpeech.EnableWindow( FALSE );
    _Btn2File.EnableWindow( FALSE );
    _BtnPlay.EnableWindow( FALSE );
  }

  return 0;
}

соответственно действия по событию окончания преобразования приобретают следующий вид:

        void CMainDlg::OnAudioStop()
{
  // разрешаем кнопку запуска процесса преобразования
  _BtnSpeech.EnableWindow( TRUE );
  _Btn2File.EnableWindow( TRUE );
    HRESULT hr;

  if ( _spIAudioMultiMediaDevice )
  {
    hr = ( ( IAudioPtr ) _spIAudioMultiMediaDevice ) ->Flush();
    _ASSERTE( hr == S_OK );
  }

  // отпускаем аудиофайл, иначе он остается в заблокированном состоянииif ( _spAudioFile )
  {
    hr = _spAudioFile->Flush();
    _ASSERTE( hr == S_OK );
  }

  if ( _access( _AudioFile, 0 ) == 0 ) 
    _BtnPlay.EnableWindow( TRUE );
}

Speech API 5.1

Идея таже самая - перенаправление вывода:

LRESULT CMainDlg::OnBnClickedBtnFile(WORD /*wNotifyCode*/, WORD /*wID*/, HWND /*hWndCtl*/, BOOL& /*bHandled*/)
{
  if ( DoDataExchange( DDX_SAVE ) )
  {
    CWaitCursor wc;

    // если есть такой файл, удаляемif ( _access( _AudioFile, 0 ) == 0 ) 
      ::DeleteFile( _AudioFile );

    _ASSERTE( _access( _AudioFile, 0 ) == -1 );

    ISpVoicePtr spVoice;
    ISpStreamPtr spStream;
    CSpStreamFormat  cAudioFmt;

    // "поднимаем" компонент
    HRESULT hr = spVoice.CreateInstance( CLSID_SpVoice );
    _ASSERTE( hr == S_OK );
    if( FAILED( hr ) )
      return 0;

    // получаем информацию о новом выбранном голосе
    ISpObjectToken* pToken = SpGetCurSelComboBoxToken( _VoicesCombo );

    // текущий голос
    ISpObjectTokenPtr pOldToken;
    hr = spVoice->GetVoice( &pOldToken );
    _ASSERTE( hr == S_OK );

    if (SUCCEEDED(hr))
    {
      // устананавливать новый голос имеет смысл только// если он действительно поменялсяif (pOldToken != pToken)
      {
        hr = spVoice->SetVoice( pToken );
        _ASSERTE( hr == S_OK );
      }
    }

    // задаем формат файла, используя указатель на предварительно заполненную структуру WAVEFORMATEX
    hr = SPBindToFile( _AudioFile, SPFM_CREATE_ALWAYS, &spStream, &SPDFID_WaveFormatEx, &_fmt );
    _ASSERTE( hr == S_OK );
    if( FAILED( hr ) )
      return 0;

    //// другой вариант - для задания формата используется перечисление SPSTREAMFORMAT//SPSTREAMFORMAT _FileFmt = SPSF_22kHz16BitMono;//hr = cAudioFmt.AssignFormat( _FileFmt );//_ASSERTE( hr == S_OK );//if( FAILED( hr ) )//  return 0;//hr = SPBindToFile( _AudioFile, SPFM_CREATE_ALWAYS, &spStream, //  &cAudioFmt.FormatId(), cAudioFmt.WaveFormatExPtr() );//_ASSERTE( hr == S_OK );//if( FAILED( hr ) )//  return 0;// задаем вывод - в аудиофайл
    hr = spVoice->SetOutput( spStream, TRUE );
    _ASSERTE( hr == S_OK );
    if( FAILED( hr ) )
      return 0;

    // SPF_DEFAULT - синхронное преобразование TTS, функция завершится, // когда преобразование будет завершено
    hr = spVoice->Speak( CA2W( _text ), SPF_DEFAULT, NULL );
    // метод возвратил ошибку? возможно дело в неподдерживаемом формате файла
    _ASSERTE( hr == S_OK );
    if( FAILED( hr ) )
      return 0;

    hr = spStream->Close();
    _ASSERTE( hr == S_OK );
  }

  return 0;
}

Мне не удалось обнаружить в документации на SAPI 5.1 методов для регулировки скорости записи в файл, аналогичных используемым в SAPI 4, судя по тестам преобразование текста для записи в файл производится с максимально возможной скоростью, загружая процессор на 100%.


Эта статья опубликована в журнале RSDN Magazine #5-2004. Информацию о журнале можно найти здесь