Как создать аудиокнигу с помощью синтезатора речи в браузере на JavaScrip

3108
Наша программа будет открывать указанную страницу в Викитеке, загружать текст произведение и создавать на его основе аудиокнигу. Чтобы не рисковать, например, прекрасное стихотворение Афанасия Фета «Я пришел к тебе с приветом!». Очень надеюсь, что копирайт по нему уже закончился.
Как создать аудиокнигу с помощью синтезатора речи в браузере на JavaScrip
Как создать аудиокнигу с помощью синтезатора речи в браузере на JavaScrip

Сжатие в формат MP3

Для синтеза звука мы воспользуемся потоковым режимом облачного сервиса cp.speechpro.com. Нужно отметить, что сервис отдает данные в формате WAV. Это, конечно, хороший формат, который передает данные без потерь. К сожалению, его достоинство является его недостатком: данные в этом формате занимают очень много места. Поэтому мы будем сохранять книгу популярном сжатом формате MP3.

Шаг 1: Подготовка

В нашей программе мы обойдемся минимальным интерфейсом, в котором будет всего четыре элемента:

  • Надпись
  • Поле для ввода URL-адреса — url
  • Кнопка — btn
  • Поле для информационного сообщения — info

Для сжатия нам потребуется библиотека LameJS, с кодом которой можно ознакомиться в репозитории https://github.com/zhuker/lamejs на GitHib. Для работы нам потребуется всего один файл lime.min.js.

<!DOCTYPE html>
<html>
<head>
  <script src="lame.min.js"></script>
</head>
<body>
  <p>Введите URL адрес в <a href="https://ru.wikisource.org">Викитеке</a>:</p>
  <div>
    <input id="url" style="width:1000px;height:30px" 
      value = "https://ru.wikisource.org/wiki/">
    <button id="btn" style="width:250px;height:30px">Синтезировать</button>
  </div>
  <div id="info"></div>
</body>
</html>

Доступ к REST API облачного сервиса производится по адресу https://cp.speechpro.com/, а для работы нам потребуется два интерфейса:

  • vksession/rest/session — для работы с сессиями
  • vktts/rest/v1/synthesize/stream — для работы с поточным синтезом речи

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

const urlNoCors = 'https://cors-anywhere.herokuapp.com/';
const urlPrefix = urlNoCors+'https://cp.speechpro.com/';
const urlSession = urlPrefix+'vksession/rest/session';
const urlSyntesizeStream = urlPrefix+'vktts/rest/v1/synthesize/stream';

const domain_id = 123;
const username = '[email protected]';
const password = '123456';

Мы собираемся читать художественные произведения, поэтому нам нужен чтец с выражением, поэтому для синтеза произведений на русском языке мы выберем голос Анны («Anna_n»). Суффикс «_n» означает, что это версия голоса, который использует нейронную сеть и отличается высоким качеством произношения.

Чтобы в голосе Анны не звучали «металлические нотки» мы выберем частоту аудиофайла sampleRate в 22050 Гц (при необходимости можно использовать и более высокую частоту в 44100 Гц).

Так как мы собираемся сжимать полученные данные в формат MP3, нам потребуется установить еще три константы:

  • частота потока sampleRateKbps, используемая форматом MP3 (указывается в килобитах в секунду)
  • число каналов numChannels
  • размер блока sampleBlockSize (согласно спецификациям формата MP3 его лучше установить кратным 576)

Для перекодировки нам нужно создать кодировщик mp3encoder с заданными параметрами. Этот кодировщик мы будем использовать ниже для преобразования данных WAV в формат MP3.

В завершение нам потребуется массив mp3Blocks, в котором мы будем хранить данные в формате MP3 после перекодировки. В принципе, можно было бы их хранить и в формате WAV, но из-за избыточного объема данные бы занимали в несколько раз больше места, чем в MP3.

По мере поступления блоков с аудиоданными мы их будем перекодировать и помещать в соответствующие ячейки массива mp3Blocks. Потом мы их сольем все вместе. Благо файлы с данными в формате MP3 можно просто соединять друг с другом без какой-либо дополнительной обработки.

В качестве последнего шага при подготовке мы устанавливаем обработчик нажатия кнопки на выполнение функции synthesizeToMp3(), описанной ниже.

const language = "Russian"
const voice = "Anna_n";

const sampleRate = 22050;	
const sampleRateKbps = 32;
const numChannels = 1;
const sampleBlockSize = 1152; // Кратное 576

let mp3encoder = new lamejs.Mp3Encoder(numChannels, sampleRate, sampleRateKbps);
let mp3Parts = [];

document.getElementById('btn').addEventListener('click',synthesizeToMp3);

Шаг 2: Загружаем книгу

Подготовим процедуру загрузки книг из Викитеки.

URL-адрес страницы с книгой мы получим как значение из HTML-элемента с идентификаторов «url».

Викитека как и другие сайты отдает страницы в формате HTML, и можно было бы распарсить полученную страницу в поисках нужного текста, но гораздо проще воспользоваться другой возможностью сайта и получить текст в исходном виде без лишней разметки. Для этого в конце URL-адреса нужно добавить параметры:

?action=raw

После запроса fetch() и его преобразования в текст с помощью функции text() мы получим текст в формате Markdown (внутренний формат Википедии и многих других вики-сайтов).

Полученный текст будет содержать текст произведения. К сожалению, Викитека не устанавливает жесткие правила форматирования текстов, но большинство страниц придерживается следующего правила:
для стихов обычно используются обрамляющие тэги <poem> и </poem>
для прозы обычно используются тэги <div class=»text»> и </div>

Теперь вырезаем текст произведения между тэгами. Если посмотреть на получившийся текст, то внутри еще можно заметить внутренню разметку Markdown, которая использует для обрамления двойные скобки: {{ и }}.

Полученный после удаления разметки текст мы разделим на абзацы по признаку двух переносов строк, идущих друг для друга. Результат сохраняется в массив phrases и в качестве результирующего значения возвращается из функции.

async function loadBook() {
  let url = document.getElementById('url').value;	
  let res = await fetch(urlNoCors+url+'?action=raw');
  let txt = await res.text();

  if(txt.includes('<poem>')) {
    txt = txt.match(/<poem>((.|[\n\r])*)<\/poem>/)[1];
  } else if(txt.match(/<div class=['"]text['"]>/)) {
    txt = txt.match(/<div class=['"]text['"]>((.|[\n\r])*)<\/div>/)[1];
  } 

  txt = txt.replace(/{{((.|[\n\r])*)}}/g,'');
  let phrases = txt.split(/\n\n/g).filter(d=>d);
  return phrases;
}

Шаг 3: Синтезируем книгу по абзацам

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

Сначала мы получим текст книги, подготовленный в виде массива абзацев, с помощью функции loadBook(), описанной выше.

Потом с откроем новую сессию с помощью функции getSession().

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

Далее в цикле мы будем синтезировать блоки, как будет описано ниже.

async function synthesizeToMp3() {
  let url = document.getElementById('url').value;
  let phrases = await loadBook(url);

  let session_id = await getSession();
  let cnt = 0;		
  for(let pos=0;pos<phrases.length;pos++) {
    //
    // Здесь будет код по синтезу каждой из фраз
    //
  }
}

Шаг 4: Отправляем блок для синтеза

Для каждого блока нам потребуется место для хранения полученных двоичных данных в виде массива int16Array. Каждый раз, получая новую порцию данных мы будет добавлять в конец этого массива данные. В начале мы создаем пустой 16-ричный массив с помощью вызова конструктора new Int16Array() без параметров.

С помощью функции openSocket() мы открываем веб-сокет, передавая в качестве параметров идентификатор сессии session_id, а также номер запроса. В качестве номера запроса мы будем использовать номер блока, который хранится в переменной цикла pos. Дополнительно мы задачем выбранный язык language и голос voice. После выполнения процедуры открытия сокета на выходе мы получаем собственно сам веб-сокет socket и номер транзакции transaction_id.

Реализация веб-сокетов в JavaScript выполнена с использованием асинхронного протокола, который вызывает заранее определенные функции при возникновении соответствующих событий:

  • onopen — после инициализации веб-сокета
  • onmessage — при получении очередной порции данных
  • onclose — после закрытия веб-сокета

После открытия веб-сокета (onopen) мы сразу отправляем очередной абзац в сокет с помощью стандартной функции socket.send(), которая принимает простой текст в качестве параметра.

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

При получении сообщения с очередной порции данных (onmessage) полученные двоичные данные добавляются в буфер int16Array с помощью описанной ниже функции appendBuffer().

При закрытии веб-сокета система кодирует данные буфера с помощью функции mp3encode().

Увеличиваем значение счетчика cnt, о чем информируем пользователя, выводя в HTML-элемент с идентификатором info соответствующее сообщение.

Если количество полученных блоков совпало с количеством отправленных блоков (количестве абзацев в тексте), то вызывается функция mp3finish(), которая завершает процесс получения данных, объединяет все в один файл в формате MP3 и сохраняет его на диск.

let int16Array = new Int16Array();
let {socket,transaction_id} = await openSocket(session_id,pos,language,voice);

socket.onopen = async function socketOnOpen(event) {
  socket.send(phrases[pos]);
  closeSocket(session_id,transaction_id);
}

socket.onmessage = async function socketOnMessage(event) {
      int16Array = self.appendBuffer(int16Array, new Int16Array(event.data));
}

socket.onclose = async function socketOnClose(event) {
  mp3encode(int16Array,pos);
  cnt++;
  document.getElementById('info').innerText = "Прочитано "+cnt+" блоков из "+phrases.length;

  if(cnt>=phrases.length) {
    mp3finish();
  }
}

Ниже приведем несколько служебных функций, отвечающих за взаимодействие с облачным сервисом, которые ранее были подробно разобраны на предыдущих уроках серии:

  • getSession() — открытие сессии
  • openSocket() — открытие веб-сокета
  • closeSocket() — закрытие веб-сокета

В конце этого раздела мы также приведем код служебной функции appendBuffer(), которая объединяет два массива с двоичными данными.

async function getSession() {
  let param = {
    domain_id,
    username,
    password
  };
  let response = await fetch(urlSession, {
    method:'POST',
    headers: {
      'Accept': 'application/json;charset=UTF-8',
      'Content-Type': 'application/json;charset=UTF-8'
    },
    body: JSON.stringify(param)
  });
  let json = await response.json();
  return json.session_id;
}

async function openSocket(session_id,request_id,language,voice) {

  let param = {
    voice_name:voice, 		// Голос
    text:{
      mime:'text/plain',
    },
    audio: "audio/wav"		
  };
  // Запрос на превращение текста в речь
  let response = await fetch(urlSyntesizeStream, {
    method:'POST',
    headers: {
      'X-Session-Id':session_id,
      'X-Request-Id':request_id,
      'Accept': 'application/json;charset=UTF-8',				
      'Content-type': 'application/json;charset=UTF-8',
    },
    body:JSON.stringify(param)
  });
  let result = await response.json(); // Результат получается в виде JSON
  let transaction_id = result.url.split("stream/")[1];
    let socket = new WebSocket(result.url);
  socket.binaryType = 'arraybuffer';
  return {socket,transaction_id};
}

async function closeSocket(session_id,request_id) {
  let response = await fetch(urlSyntesizeStream, {
    method:'DELETE',
    headers: {
      'X-Session-Id':session_id,
      'X-Transaction-Id':request_id,
      'Accept': 'application/json;charset=UTF-8',				
      'Content-type': 'application/json;charset=UTF-8',
    },
  });
  return response;
}

function appendBuffer(buffer1, buffer2) {
  let tmp = new Int16Array(buffer1.byteLength / 2 + buffer2.byteLength / 2);
  tmp.set(new Int16Array(buffer1), 0);
  tmp.set(new Int16Array(buffer2), buffer1.byteLength / 2);
  return tmp;
};

Шаг 5: Кодируем аудиоданные в формат MP3

Полученные двоичные данные мы перекодируем в формат MP3 с помощью библиотеки LameJS. Для этого данные будут разделены на отдельные небольшие блоки размером sampleBlockSize, установленного нами ранее на шаге 1.

Каждый блок кодируется с помощью метода mp3encoder.encodeBuffer().

Если количество данных не кратно размеру блока, то оставшийся «хвост» перекодируется с помощью функции mp3encoder.flush().

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

function mp3encode(samples,pos) {		
  var mp3Data = [];

  for (var i = 0; i < samples.length; i += sampleBlockSize) {
    let sampleChunk = samples.subarray(i, i + sampleBlockSize);
    var mp3buf = mp3encoder.encodeBuffer(sampleChunk);
    if (mp3buf.length > 0) {
      mp3Data.push(mp3buf);
    }
  }

  var mp3buf = mp3encoder.flush(); 
  if (mp3buf.length > 0) {
    mp3Data.push(new Int8Array(mp3buf));
  }		

  mp3Parts[pos] = mp3Data;
}

Шаг 6: Объединяем все в один большой MP3-файл

После того, как все блоки получены, мы объединяем все блоки в массиве mp3Parts в один большой блок с помощью метода flat().

Далее общий массив преобразуется в блоб с типом «audiio/mp3».

Полученный блок сохраняется на диск с помощью функции saveBlob(), описанной ниже. Браузер предложит уточнить имя файла и каталог на диске для сохранения.

И все.

function mp3finish() {
  let blob = new Blob(mp3Parts.flat(), {type: 'audio/mp3'});
  saveBlob(blob, 'book.mp3');			
}

В завершение приведем код функции saveBlob().

Для того, чтобы обойти функции защиты браузера от несанкционированного сохранения фалов, функция использует небольшой «трюк» — создает ссылку и потом эмулирует нажатие на нее.

const saveBlob = (function () {
    var a = document.createElement("a");
    document.body.appendChild(a);
    a.style = "display: none";
    return function (blob, fileName) {
        var url = window.URL.createObjectURL(blob);
        a.href = url;
        a.download = fileName;
        a.click();
        window.URL.revokeObjectURL(url);
    };
}());

Вместо завершения

В результате система подготовила файл в формате MP3 и предложила сохранить его на диск. Хотя стандартный голос очень неплохо разбирается в тонкостях интонации русского языка, но его еще можно улучшить добавим нужные смысловые паузы или сделал ударения. Для этого используется специальная SSML разметка текста, которую мы рассмотрим в следующих уроках.

Код программы, приведенный в этой статье, можно посмотреть в нашем репозитории GitHub.