предисловие
Недавно я работаю над проектом, и мне нужно обработать звук, записанный с помощью webRTC, включая обрезку звука, слияние нескольких звуков и даже замену части звука другим звуком.
Изначально автор планировал сдать эту работу на сервер для завершения, но учитывая, что по сути, будь то front-end или back-end, проделанная работа аналогична, и сдача на сервер требует дополнительного процесса загрузка и выгрузка аудио.Это не только увеличивает нагрузку на сервер, но и накладные расходы на сетевой трафик, поэтому возникает мысль: почему фронтенд не может заниматься обработкой аудио?
Таким образом, благодаря полуисследованию и полупрактике автора эта статья была создана. Хватит нести чушь, иди первымАдрес склада, это готовый интерфейсный аудиоклип sdk (если щелкнуть по нему, просто пометить)
ffmpeg
ffmpegЭто очень важный модуль для реализации интерфейсной обработки аудио.Конечно, ffmpge представляет собой не только интерфейсную часть, но и зрелое и комплексное решение в отрасли, обеспечивающее запись, преобразование и потоковую передачу аудио и видео.Он также используется на стороне сервера, в приложениях APP и других сценариях. Что касается введения ffmpeg, вы можете сами погуглить, и я не буду здесь много говорить.
Так как ffmpeg требует много вычислений в процессе обработки, запустить его напрямую на front-end странице невозможно, потому что нам нужно открывать отдельный веб-воркер, чтобы запускать его в воркере, не блокируя взаимодействие со страницей.
К счастью, на всемогущем гитхабе уже есть разработчики.ffmpge.jsи предоставляет рабочую версию, которую можно использовать напрямую.
Итак, у нас есть общая идея: когда аудиофайл получен, он декодируется и отправляется в worker для обработки вычислений, а результат обработки возвращается как событие, так что мы можем делать с аудио все, что захотим :)
Необходимая работа перед началом прекрасного путешествия
Что нужно заявить заранее, так это то, что из-за требований автора проекта необходимо только.mp3Формат обрабатывается, поэтому следующий пример кода и код, связанный с адресом склада, предназначены в основном дляmp3, конечно, независимо от того, какой это формат, идея похожа.
создать работника
Способ создания воркера очень прост, просто создайте его напрямую.Обратите внимание, что из-за ограничений политики одного и того же источника, чтобы заставить воркер нормально работать, он должен иметь то же происхождение, что и родительская страница. не в этом дело, пропусти.
function createWorker(workerPath: string) {
const worker = new Worker(workerPath);
return worker;
}
postMessage для обещания
Смотри внимательноffmpeg.jsДетская обувь документа обнаружит, что он будет передавать события на родительскую страницу на разных этапах обработки звука, напримерstdout,startа такжеdoneПодождите, если вы напрямую добавите функции обратного вызова к этим событиям, будет непросто поддерживать функцию обратного вызова, чтобы различать и обрабатывать один аудио результат за другим. Лично я предпочитаю превратить это в обещание:
function pmToPromise(worker, postInfo) {
return new Promise((resolve, reject) => {
// 成功回调
const successHandler = function(event) {
switch (event.data.type) {
case "stdout":
console.log("worker stdout: ", event.data.data);
break;
case "start":
console.log("worker receive your command and start to work:)");
break;
case "done":
worker.removeEventListener("message", successHandler);
resolve(event);
break;
default:
break;
}
};
// 异常捕获
const failHandler = function(error) {
worker.removeEventListener("error", failHandler);
reject(error);
};
worker.addEventListener("message", successHandler);
worker.addEventListener("error", failHandler);
postInfo && worker.postMessage(postInfo);
});
}
Благодаря этому уровню преобразования мы можем преобразовать запрос postMessage в обещание для обработки, которое легче расширять в пространстве.
Взаимное преобразование аудио, BLOB-объектов и arrayBuffer
ffmpeg-workerТребуемый формат данныхarrayBuffer, и обычно то, что мы можем использовать напрямую, это либо объект аудиофайлаblobили объект аудиоэлементаaudio, это может быть даже просто url ссылки, поэтому конвертация этих форматов очень нужна:
аудио в массивBuffer
function audioToBlob(audio) {
const url = audio.src;
if (url) {
return axios({
url,
method: 'get',
responseType: 'arraybuffer',
}).then(res => res.data);
} else {
return Promise.resolve(null);
}
}
Способ, которым автор временно думает о преобразовании аудио в большой двоичный объект, заключается в том, чтобы инициировать запрос ajax и установить тип запроса наarraybuffer, вы можете получитьarrayBuffer.
большой двоичный объект в массивбуфер
Это также очень просто, просто используйтеFileReaderИзвлечь содержимое большого двоичного объекта
function blobToArrayBuffer(blob) {
return new Promise(resolve => {
const fileReader = new FileReader();
fileReader.onload = function() {
resolve(fileReader.result);
};
fileReader.readAsArrayBuffer(blob);
});
}
arrayBuffer в большой двоичный объект
использоватьFileсоздатьblob
function audioBufferToBlob(arrayBuffer) {
const file = new File([arrayBuffer], 'test.mp3', {
type: 'audio/mp3',
});
return file;
}
капля в аудио
blobПреобразование аудио очень просто, js предоставляет собственный API -URL.createObjectURL, с помощью которого мы можем преобразовать большой двоичный объект в локально доступную ссылку для воспроизведения.
function blobToAudio(blob) {
const url = URL.createObjectURL(blob);
return new Audio(url);
}
Далее переходим к делу.
Обрезка аудио - клип
Так называемая обрезка относится к извлечению содержимого этой части данного аудио в соответствии с заданными временными точками начала и окончания для формирования нового аудио, первого кода:
class Sdk {
end = "end";
// other code...
/**
* 将传入的一段音频blob,按照指定的时间位置进行裁剪
* @param originBlob 待处理的音频
* @param startSecond 开始裁剪时间点(秒)
* @param endSecond 结束裁剪时间点(秒)
*/
clip = async (originBlob, startSecond, endSecond) => {
const ss = startSecond;
// 获取需要裁剪的时长,若不传endSecond,则默认裁剪到末尾
const d = isNumber(endSecond) ? endSecond - startSecond : this.end;
// 将blob转换成可处理的arrayBuffer
const originAb = await blobToArrayBuffer(originBlob);
let resultArrBuf;
// 获取发送给ffmpge-worker的指令,并发送给worker,等待其裁剪完成
if (d === this.end) {
resultArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, ss)
)).data.data.MEMFS[0].data;
} else {
resultArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, ss, d)
)).data.data.MEMFS[0].data;
}
// 将worker处理过后的arrayBuffer包装成blob,并返回
return audioBufferToBlob(resultArrBuf);
};
}
Мы определяем три параметра этого интерфейса: звуковой блоб, который необходимо обрезать, и начальную и конечную временные точки отсечения.Стоит отметить, что здесьgetClipCommandфункция, отвечающая за преобразование входящегоarrayBuffer, упакованный во времени вffmpeg-workerсогласованный формат данных
/**
* 按ffmpeg文档要求,将带裁剪数据转换成指定格式
* @param arrayBuffer 待处理的音频buffer
* @param st 开始裁剪时间点(秒)
* @param duration 裁剪时长
*/
function getClipCommand(arrayBuffer, st, duration) {
return {
type: "run",
arguments: `-ss ${st} -i input.mp3 ${
duration ? `-t ${duration} ` : ""
}-acodec copy output.mp3`.split(" "),
MEMFS: [
{
data: new Uint8Array(arrayBuffer),
name: "input.mp3"
}
]
};
}
Мульти-аудио синтез - concat
Синтез мультиаудио хорошо понятен, он будет включен в аудио в порядке нескольких аудио.
class Sdk {
// other code...
/**
* 将传入的一段音频blob,按照指定的时间位置进行裁剪
* @param blobs 待处理的音频blob数组
*/
concat = async blobs => {
const arrBufs = [];
for (let i = 0; i < blobs.length; i++) {
arrBufs.push(await blobToArrayBuffer(blobs[i]));
}
const result = await pmToPromise(
this.worker,
await getCombineCommand(arrBufs),
);
return audioBufferToBlob(result.data.data.MEMFS[0].data);
};
}
В приведенном выше коде мы передаемforЦикл для декодирования BLOB-объектов в массиве один за другим вarrayBuffer, некоторые детские туфли могут быть любопытны: почему бы не использовать напрямую собственный массивforEachметод его прохождения? НапишитеforЗацикливание немного хлопотно. На самом деле есть причина: мы используемawait, ожидается, что после того, как эти блобы будут декодированы один за другим, будет выполнен следующий код,forЦикл выполняется синхронно, ноforEachКаждое тело цикла выполняется асинхронно отдельно, мы не можем передатьawaitспособ дождаться их завершения, поэтому используйтеforEachНе оправдал наших ожиданий.
такой же,getCombineCommandОбязанности функции такие же, как указано выше.getClipCommandаналогичный:
async function getCombineCommand(arrayBuffers) {
// 将arrayBuffers分别转成ffmpeg-worker指定的数据格式
const files = arrayBuffers.map((arrayBuffer, index) => ({
data: new Uint8Array(arrayBuffer),
name: `input${index}.mp3`,
}));
// 创建一个txt文本,用于告诉ffmpeg我们所需进行合并的音频文件有哪些(类似这些文件的一个映射表)
const txtContent = [files.map(f => `file '${f.name}'`).join('\n')];
const txtBlob = new Blob(txtContent, { type: 'text/txt' });
const fileArrayBuffer = await blobToArrayBuffer(txtBlob);
// 将txt文件也一并推入到即将发送给ffmpeg-worker的文件列表中
files.push({
data: new Uint8Array(fileArrayBuffer),
name: 'filelist.txt',
});
return {
type: 'run',
arguments: `-f concat -i filelist.txt -c copy output.mp3`.split(' '),
MEMFS: files,
};
}
В приведенном выше коде, в отличие от операции отсечения, используется не один, а несколько аудиообъектов, поэтому необходимо создать «таблицу сопоставления», чтобы указатьffmpeg-workerОбщее количество звуков, которые необходимо объединить, и порядок их объединения.
Замена обрезки аудио - склейка
это немного похожеclipВ обновленной версии мы удаляем звук A из указанного места и вставляем сюда звук B:
class Sdk {
end = "end";
// other code...
/**
* 将一段音频blob,按指定的位置替换成另一端音频
* @param originBlob 待处理的音频blob
* @param startSecond 起始时间点(秒)
* @param endSecond 结束时间点(秒)
* @param insertBlob 被替换的音频blob
*/
splice = async (originBlob, startSecond, endSecond, insertBlob) => {
const ss = startSecond;
const es = isNumber(endSecond) ? endSecond : this.end;
// 若insertBlob不存在,则仅删除音频的指定内容
insertBlob = insertBlob
? insertBlob
: endSecond && !isNumber(endSecond)
? endSecond
: null;
const originAb = await blobToArrayBuffer(originBlob);
let leftSideArrBuf, rightSideArrBuf;
// 将音频先按指定位置裁剪分割
if (ss === 0 && es === this.end) {
// 裁剪全部
return null;
} else if (ss === 0) {
// 从头开始裁剪
rightSideArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, es)
)).data.data.MEMFS[0].data;
} else if (ss !== 0 && es === this.end) {
// 裁剪至尾部
leftSideArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, 0, ss)
)).data.data.MEMFS[0].data;
} else {
// 局部裁剪
leftSideArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, 0, ss)
)).data.data.MEMFS[0].data;
rightSideArrBuf = (await pmToPromise(
this.worker,
getClipCommand(originAb, es)
)).data.data.MEMFS[0].data;
}
// 将多个音频重新合并
const arrBufs = [];
leftSideArrBuf && arrBufs.push(leftSideArrBuf);
insertBlob && arrBufs.push(await blobToArrayBuffer(insertBlob));
rightSideArrBuf && arrBufs.push(rightSideArrBuf);
const combindResult = await pmToPromise(
this.worker,
await getCombineCommand(arrBufs)
);
return audioBufferToBlob(combindResult.data.data.MEMFS[0].data);
};
}
Приведенный выше код чем-то похожclipа такжеconcatсоставное использование.
На данный момент наши потребности в основном достигнуты.Только с помощью работников интерфейс также может сам обрабатывать звук.Разве это не прекрасно?
Приведенные выше коды предназначены только для лучшего объяснения, поэтому сделаны некоторые упрощения, и заинтересованная детская обувь может напрямуюисходный код, добро пожаловать на общение и стрелять кирпичами :)