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