
Сжатие в формат 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 = 'user@gmail.com'; 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.

















