Привет! Я Алекс.  

И я уже долгое время являюсь разработчиком на движке “Юнити”. В моем портфолио не очень много игровых проектов, но я достаточно часто занимался разработкой в иных направлениях - симуляторы, “энтертаймент”, инструменты для художников и “креаторов”, VR-приложения для медицины и обучения, площадки для виртуальных концертов и другое. Думаю, никому не нужно рассказывать, что спектр применения “Юнити” огромен.  
 
В ходе работы я часто встречался с нестандартными, но интересными (на мой субъективный взгляд) задачами, решение которых нельзя вот так сразу найти в первых строчках Гугла. И вот тут, я хочу начать цикл своих статей (очень надеюсь, что в цикле будет больше одной статьи ????), в котором я хочу поделиться опытом решением некоторых таких задач.  
 
Сразу хочу оговориться, я ни в коем случае не претендую на звание профи, я всего лишь делюсь своим опытом. Я уверен, что многие из вас предложат гораздо лучшие и более элегантные решения. Буду очень рад, если вы напишете об этом в комментариях. Относитесь к этому циклу просто, как к попытке очередного “юнитиста” засветиться и сделать свой профиль более привлекательным в глазах работодателей.  
Но все же, если кто-то в моих тренировках красноречия найдет что-то полезное для себя, я буду счастлив. 
 
Начать мне хотелось бы с чего-нибудь простого. 


Условие

В приложении должно быть большое количество персонажей разного пола, возраста, телосложения, и просто невероятное количество фраз, которые они должны произносить в разных ситуациях. Но, естественно, у вас нет ни такого количества разноголосых актеров, ни возможности нормальной звукозаписи. А уже почти готовое приложение от такого количества аудиофайлов увеличится раз в 20, что неприемлемо. 
 
Именно с такой задачей я столкнулся в одном из проектов для крупного медицинского университета. Приложение представляло собой прием у врача, где студенты в роли врача диагностировали виртуального пациента. Одним из составляющих диагностики как раз и являлся диалог с пациентом.  Огромнейшая база всевозможных типов заболеваний и вариаций ответов на различные вопросы не позволяли сделать это классическим образом. 

Заказчику не нужна была выдающееся актерская игра, поэтому было решено обратиться к Text-To-Speech онлайн сервисам. Первым в голову, конечно, пришел Google Cloud.  Text-to-Speech: Lifelike Speech Synthesis  |  Google Cloud 
 
Большое количество голосов на разных языках, что отлично для локализации приложения (но об этом как-нибудь потом), их гибкая настройка и даже возможность создания “кастомного” голоса. В общем, выбор стал очевиден. 
 
Для меня было важно, чтобы все работало на любых платформах. Будь то ПК, android, ios или WebGL (в первую очередь приложение должно было работать на шлеме Oculus Quest 2). Поэтому я решил отказаться от каких-либо готовых библиотек (да и лишний функционал совсем не нужен). Ведь все решилось обыкновенным POST-запросом. Но обо всем по порядку. 
 
Идем в консоль Google Cloud и создаем новый проект. 
New Project – Google Cloud console 
 
В поиск вбиваем Cloud Speech-to-Text API и жмем Enable, чтобы активировать.

Добавляем билинг, если он у Вас еще не настроен, и подключаем его к проекту. 
Enable, disable, or change billing for a project  |  Cloud Billing  |  Google Cloud 

В Credentials создаем новый ключ.


В Restrict Key выбираем Cloud Text-to-Speech API и запоминаем наш API Key. 

С настройками консоли пока все.    

Как я говорил ранее, чтобы получить аудио файл из нашего текста, нам нужно отправить POST запрос на сервер Гугла. А именно на https://texttospeech.googleapis.com/v1/text:synthesize 
 
Проверим работоспособность запроса в Postman. 
В тело запроса пишем следующее: 

{
  "audioConfig": {
    "audioEncoding": "MP3",
    "pitch": 0,
    "speakingRate": 1
  },
  "input": {
    	"text": "Hello world"
  },
  "voice": {
    "languageCode": "en-US",
    "name": "en-US-Wavenet-D"
  }
}

Где "text" - текст, который нужно озвучить, а "voice" и "audioConfig" - параметры голоса.

Отправляем... и получаем ошибку.

А все потому, что в ”хедере” мы не указали тот самый API Key, который мы взяли из консоли. Но зато увидели формат вывода ошибки :)

Добавляем в Headers следующие параметры.

Где X-Goog-Api-Key - наш ключ.

Запускаем еще раз и получаем следующее.

Вот он, наш аудиофайл. Правда сейчас он в кодировке Base64, но это не проблема. С этим можно работать.

Запускаем Юнити и начинаем “кодить”. Для начала, т.к. мы уже знаем форматы JSONок, с которыми работает POST-запрос, создадим все необходимые дата-классы. 

Классы для отправки:

[Serializable]
public class DataToSend
{
    public Input input;
    public Voice voice;
    public AudioConfig audioConfig;
}

[Serializable]
public class Input
{
    public string text;
}

[Serializable]
public class Voice
{
    public string languageCode;
    public string name;
}

[Serializable]
public class AudioConfig
{
    public string audioEncoding;
    public float pitch;
    public float speakingRate;
}

Класс получения аудио:

[Serializable]
public class AudioData
{
    public string audioContent;
}

И классы ошибки:

[Serializable]
public class BadRequestData
{
    public Error error;
}

[Serializable]
public class Error
{
    public int code;
    public string message;
    public string status;
    public List<Detail> details;
}

[Serializable]
public class Detail
{
    public string reason;
    public string domain;
    public Metadata metadata;
}

[Serializable]
public class Metadata
{
    public string service;
}

Параметры голоса будем задавать с помощью ScriptableObjects. Для них тоже создадим класс.

[CreateAssetMenu(fileName = "Voice", menuName = "GoogleTextToSpeech/Voice", order = 1)]
public class VoiceScriptableObject : ScriptableObject
{
    public string languageCode;
    public new string name;
    [Range(0.25f, 4f)]
    public float speed = 1;
    [Range(-20f, 20f)]
    public float pitch;
}

Теперь напишем сервис, который будет работать с нашим запросом.

public class RequestService : MonoBehaviour
{
    public static void SendDataToGoogle(string url, DataToSend dataToSend, string apiKey, Action<string> requestReceived,
        Action<BadRequestData> errorReceived)
    {
        var headers = new Dictionary<string, string>();
        headers.Add("X-Goog-Api-Key", apiKey);
        headers.Add("Content-Type", "application/json; charset=utf-8");
        
       Post(url, JsonUtility.ToJson(dataToSend), requestReceived,  errorReceived, headers);
    }
    
   private static async void Post(string url, string bodyJsonString, Action<string> requestReceived,
        Action<BadRequestData> errorReceived, Dictionary<string, string> headers = null)
    {
        var request = new UnityWebRequest(url, "POST");
        var bodyRaw = Encoding.UTF8.GetBytes(bodyJsonString);
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        if (headers != null)
        {
            foreach (var header in headers)
            {
                request.SetRequestHeader(header.Key, header.Value);
            }
        }

        var operation = request.SendWebRequest();

        while (!operation.isDone)
            await Task.Yield();

        if (HasError(request, out var badRequest))
        {
            errorReceived?.Invoke(badRequest);
        }
        else
        {
            requestReceived?.Invoke(request.downloadHandler.text);
        }
        
       request.Dispose();
    }

    private static bool HasError(UnityWebRequest request, out BadRequestData badRequestData)
    {
        if (request.responseCode is 200 or 201)
        {
            badRequestData = null;
            return false;
        }

        badRequestData = JsonUtility.FromJson<BadRequestData>(request.downloadHandler.text);

        try
        {
            badRequestData = JsonUtility.FromJson<BadRequestData>(request.downloadHandler.text);
            return true;
        }
        catch (Exception)
        {
            badRequestData = new BadRequestData
            {
                error = new Error
                {
                    code = (int)request.responseCode,
                    message = request.error
                }
            };

            return true;

Метод void Post тут универсален и позволяет задать любой хедер (Dictionary<string, string> headers) и тело запроса (string bodyJsonString). В методе есть два экшена, которые срабатываю после ответа сервера. Action<string> requestReceived  - в случае удачи и Action<BadRequestData> errorReceived - в случае ошибки. 
Метод bool HasError выявляет была ли ошибка. Если приходит 200 или 201 ответ - все “ок”, если нет - записывает информацию об ошибки в BadRequestData и запускает  ”экшон” errorReceived.

Метод SendDataToGoogle унифицирует метод Post, добавляя Headers в него. 

Мы помним, что аудио приходит к нам в Base64 кодировке. Поэтому, чтобы сохранить его в mp3-файл используем:

public static void SaveTextToMp3(AudioData audioData)
{
    var bytes = Convert.FromBase64String(audioData.audioContent);
    File.WriteAllBytes(Application.temporaryCachePath + "/" + Mp3FileName, bytes);
}

Теперь мы можем загружать этот файл и использовать его в качестве AudioClip в нашем проекте.

public class AudioConverter : MonoBehaviour
{
    private const string Mp3FileName = "audio.mp3";

    public static void SaveTextToMp3(AudioData audioData)
    {
        var bytes = Convert.FromBase64String(audioData.audioContent);
        File.WriteAllBytes(Application.temporaryCachePath + "/" + Mp3FileName, bytes);
    }

    public void LoadClipFromMp3(Action<AudioClip> onClipLoaded)
    {
        StartCoroutine(LoadClipFromMp3Cor(onClipLoaded));
    }

    private static IEnumerator LoadClipFromMp3Cor(Action<AudioClip> onClipLoaded)
    {
        var downloadHandler =
            new DownloadHandlerAudioClip("file://" + Application.temporaryCachePath + "/" + Mp3FileName,
                AudioType.MPEG);
        downloadHandler.compressed = false;

        using var webRequest = new UnityWebRequest("file://" + Application.temporaryCachePath + "/" + Mp3FileName,
            "GET",
            downloadHandler, null);

        yield return webRequest.SendWebRequest();

        if (webRequest.responseCode == 200)
        {
            onClipLoaded.Invoke(downloadHandler.audioClip);
        }
        
       downloadHandler.Dispose();
    }
}

Осталось создать основной класс, который построит взаимодействие RequestService и  AudioConverter.

public class TextToSpeech : MonoBehaviour
{
    [SerializeField] private string apiKey;

    private Action<string> _actionRequestReceived;
    private Action<BadRequestData> _errorReceived;
    private Action<AudioClip> _audioClipReceived;

    private RequestService _requestService;
    private static AudioConverter _audioConverter;

    public void GetSpeechAudioFromGoogle(string textToConvert, VoiceScriptableObject voice, Action<AudioClip> audioClipReceived,  Action<BadRequestData> errorReceived)
    {
        _actionRequestReceived += (requestData => RequestReceived(requestData,audioClipReceived));

        if (_requestService == null)
            _requestService = gameObject.AddComponent<RequestService>();

        if (_audioConverter == null)
            _audioConverter = gameObject.AddComponent<AudioConverter>();

        var dataToSend = new DataToSend
        {
            input =
                new Input()
                {
                    text = textToConvert
                },
            voice =
                new Voice()
                {
                    languageCode = voice.languageCode,
                    name = voice.name
                },
            audioConfig =
                new AudioConfig()
                {
                    audioEncoding = "MP3",
                    pitch = voice.pitch,
                    speakingRate = voice.speed
                }
        };

        RequestService.SendDataToGoogle("https://texttospeech.googleapis.com/v1/text:synthesize", dataToSend,
            apiKey, _actionRequestReceived, errorReceived);
    }

    private static void RequestReceived(string requestData, Action<AudioClip> audioClipReceived)
    {
        var audioData = JsonUtility.FromJson<AudioData>(requestData);
        AudioConverter.SaveTextToMp3(audioData);
        _audioConverter.LoadClipFromMp3(audioClipReceived);
    }
}

apiKey – наш ключ из консоли Гугла.

Метод void GetSpeechAudioFromGoogle имеет следующие параметры:

string textToConvert - текст который нужно озвучить,

VoiceScriptableObject voice – ScriptableObject с настройками голоса.

Action<AudioClip> audioClipReceived - экшн в случае удачного создания AudioClip.

Action<BadRequestData> errorReceived - экшн в случае ошибки.

Весь код и пример работы вы можете найти у меня на Github:

 anomalisfree/Unity-Text-to-Speech-using-Google-Cloud (github.com)


Небольшой бонус.  Скрипт, который позволит, персонажам двигать ртом в такт голоса.

public AudioSource audioSource;
public float updateStep = 0.1f;
public int sampleDataLength = 1024;

private float currentUpdateTime = 0f;
private float clipLoudness;
private float[] clipSampleData;

private void Update()
{
    currentUpdateTime += Time.deltaTime;

    if (audioSource.isPlaying && currentUpdateTime >= updateStep)
    {
        currentUpdateTime = 0f;
        audioSource.clip.GetData(clipSampleData, audioSource.timeSamples);
        clipLoudness = 0f;

        foreach (var sample in clipSampleData)
        {
            clipLoudness += Mathf.Abs(sample);
        }

        clipLoudness /= sampleDataLength; //clipLoudness is what you are looking for
    }
}

Осталось только сделать открытие рта зависимым от параметра clipLoudness.

Например, поворот челюсти.

_currentJawRot = Mathf.MoveTowards(_currentJawRot, clipLoudness * 100, Time.deltaTime * 1000);

На этом пока все. Приглашаю всех в комментарии.

Комментарии (0)