들어가며
만드는 서비스 중에 AI를 통해 음원을 분리해주는 "Source Separation"이 있습니다.
음원을 분리하여 유저에게 분리된 음원소스를 다운로드하는 기능을 제공하고 있습니다.
최근 유저 피드백 중에 분리된 음원을 합쳐서 하나의 파일로 다운로드 하는 기능이 들어와 개발을 하게 되었습니다.
이번 글에서는 간단하게 라이브러리를 이용해 기능을 만들어보았습니다.
이후 속도 개선을 위해 Web Assembly 기반의 FFmepg wasm을 사용하고,
UI Block를 방지하기 위해 변환 기능을 Web Worker 상에서 동작하는 방법에 대해 알아보겠습니다.
결론부터 말하자면 웹어셈블리는 매우 짱짱이었고 테스트 데이터 결과 15~30배의 성능개선이 이뤄졌습니다.
대상 독자
- 브라우저에서 오디오파일을 다루는 개발자
- ffmpegwasm를 Next.js에서 사용하고픈 개발자
아직은 베타버전이지만 짤막 홍보를 ㅎㅎ
오디오 파일 다루기
오디오 파일을 다루기에는 생각보다 양이 방대하여 제가 학습한 사이트중에 좋은 사이트를 선별해보았습니다.
(저도 언젠가 저런 깊은 수준의 글을 쓸수 있기를 바라며..)
카레유 | 자바스크립트의 Media 다루는 방법
Evans | 컴퓨터가 Audio를 다루는 방법
위의 2개를 읽고 오시면 브라우저에서 오디오를 다루는 방법에 대한 기초지식을 습득하셨을 것입니다.
구현
분리된 음원을 합치는 기능을 개발하기에 앞서 현재 서비스의 배경을 설명하고 가도록 하겠습니다.
배경 설명
이전에 글에서도 언급했다시피 IndexedDB를 이용해 데이터를 관리하고 있었습니다.
위의 글을 간단히 요약하자면 아래와 같습니다.
- 오디오파일의 용량은 상대적으로 대용량이다. (MB단위)
- 매번 로딩하기엔 대용량이니 브라우저에 저장을 하자.
- 일반 스토리지에 저장하기엔 대용량이니 IndexedDB에 저장을 하자.
- 일반적으로 오디오파일은 ArrayBuffer나 Blob으로 관리하니 IndexedDB에 적합하다.
여기서 눈여겨봐야할 점은 'ArrayBuffer나 Blob'입니다.
그래서 각각의 음원을 가져올때 Blob으로 가져와서 waveform에 넣어주면 다음과 같이 재생할 수 있는 Player가 만들어졌습니다.
그리고 Mixed 음원은 아래와 같은 유저의 컨트롤을 반영해서 음원이 완성되어야합니다.
유저 컨트롤 = Vocal, Drum만 재생, Vocal은 볼륨 100, Drum은 볼륨 50
제가 사용하고 있는 라이브러리는 Blob 또는 Audio File을 넣어주면 내부적으로 audioBuffer로 변환하여 데이터를 관리하고 이를 wave로 랜더링해줍니다.
그리고 현재 상태의 audioBuffer를 가져올 수 있는 기능이 존재합니다.
해당 라이브러리에서는 이 기능을 통해 Mixed Audio Download를 지원합니다만 오픈되어 있지 않습니다.
따라서 커스텀해서 만들었어야했습니다.
방법 1 : audio-encoder
우선 라이브러리를 찾아가 보겠습니다.
사용법
npm i audio-encoder
npm i file-saver
편리한 다운로드를 위해 file-saver도 함께 설치합니다.
import audioEncoder from "audio-encoder";
import { saveAs } from "file-saver";
// audioEncoder(audioBuffer, encoding, onProgress, onComplete);
audioEncoder(audioBuffer, 128, null, function onComplete(blob) {
saveAs(blob, "Mixed.mp3");
});
사용법은 아주 간단합니다. 각각에 맞춰 값을 전달해주면 끝입니다.
// waveform-playlist에서 현재 상태의 audioArray를 가져오기위한 Trigger
playlist.current
.getEventEmitter()
.on("audiorenderingfinished", async (type, data) => {
audioEncoder(
data,
128,
function onProgress(progress) {
console.log(progress);
},
function onComplete(blob) {
saveAs(blob, "Mixed.mp3");
}
);
});
이 라이브러리의 구현체도 간단하니 한번 살펴보도록 하겠습니다.
var encodeWav = require('./encodeWav');
var encodeMp3 = require('./encodeMp3');
var VALID_MP3_BITRATES = [32, 40, 48, 56, 64, 96, 128, 192, 256, 320];
module.exports = function encode (audioBuffer, encoding, onProgress, onComplete) {
if (!encoding || encoding === 'WAV') {
return encodeWav(audioBuffer, onComplete);
}
encoding = ~~encoding;
if (VALID_MP3_BITRATES.indexOf(encoding) === -1) {
throw new Error('Invalid encoding');
}
return encodeMp3(audioBuffer, { bitrate: encoding }, onProgress, onComplete);
};
mp3에 적합한 encoding bitrate값을 주면 mp3 아니면 wav로 encode하는 로직입니다.
encodeMp3의 내부 코드를 살펴보면 lamejs라는 mp3 encoder를 사용하고 있습니다.
lamejs는 자바스크립트 mp3 encoder입니다. 개별적으로도 사용할 수 있으며 이후에 나올 FFmpeg에서도 이 라이브러리를 통해 mp3 encoder를 합니다.
[경고]
최신버전에서 dev dependency로 걸린 lamejs의 이슈로 mp3로의 전환이 동작하지 않습니다.
lamejs의 버전업으로 해결되야하지만 반영이 안되고 있으니 참고 바랍니다.
따라서 최신버전 대신 1.0.2를 사용하시면 되겠습니다.
성능
구현을 했으니 이제 성능을 측정해보겠습니다. console.time 과 console.timeEnd를 이용해서 측정하였습니다.
음원 | 노래 원본 길이 | 트랙수 | mp3 생성 시간 |
아이유 라일락 | 1분 | 3 | 46722.56 ms |
아이유 라일락 | 1분 | 6 | 47229.04 ms |
아이유 라일락 | 4분 39초 | 3 | 279521.36 ms |
아이브 러브다이브 | 1분 | 6 | 49939.08 ms |
성능을 보면 처참합니다.
원본의 0.8~ 0.9의 시간이 걸리는 극악의 변환속도를 보니 실서비스에서 사용할수 없는 수준이라고 판단하였습니다.
방법 2 : ChatCPT
방법을 고민하기 전에 요즘 핫한 ChatCPT에게 물어보겠습니다.
결론부터 요약하자면 자바스크립트 혼자서 돌리기엔 역량이 부족하다고 합니다.
그리고 서버에서 처리하는 방법에 대한 개괄적인 순서를 보여줍니다....
ChatGPT도 손절한 audiobuffer to mp3를 이제 다시 해보러 가겠습니다.
아직 ChatGPT보다 개발자가 쓸만함을 보여줍시다!!
방법 3: FFmpeg.wasm
위에서 ChatGPT가 말하길 서버에서 FFmpeg를 돌려서 변환하라고 되어있습니다.
기본적인 이유는 mp3는 압축된 포멧입니다. 그리고 기본적으로 압축하는 알고리즘은 시간이 걸립니다.
그러니 성능이 상대적으로 좋은 서버에서 변환하여 클라이언트에 전달하라고 하는 것입니다.
하지만 모던 프론트엔드의 발전으로 FFmpeg도 브라우저상에서 나름 괜찮은 속도로 돌릴 수 있게 되었습니다.
바로 "FFmpeg wasm" 입니다. FFmpeg의 웹어셈블리 버전입니다.
FFmpeg 이란
멀티미디어와 스트림을 다루기 위한 무료 오픈소스 소프트웨어입니다.
decoding, encoding, transcoding, muxing, demuxing, streaming, filtering and playing 등 미디어와 관련된 다양한 기능을 지원하며 매우 광범위하고 범용적으로 사용되고 있습니다.
FFmpeg is a free, open-source software project that provides a comprehensive suite of libraries and programs for handling multimedia files and streams. It is capable of decoding, encoding, transcoding, muxing, demuxing, streaming, filtering and playing most audio and video formats. FFmpeg is widely used for various purposes, including video compression, video conversion, video editing, and more.
FFmpeg wasm이란
그리고 이를 브라우저에서 돌리기 위한 라이브러리로 FFmpeg wasm이 만들어졌습니다.
아무래도 멀티미디어를 다루기 때문에 브라우저상에서 돌릴때는 성능상의 한계가 있었는데, 이를 웹어셈블리를 이용한 사용할만한 속도를 제공하는게 해당 프로젝트의 목표라고 할 수 있습니다.
깃헙문서를 보면 still very experimental (and slow)이라고 되어있습니다만 실제로 사용해버니 쓸만 합니다.
ffmpeg.wasm is a pure Webassembly / Javascript port of FFmpeg. It enables video & audio record, convert and stream right inside browsers.
// FFmpeg. core
This is the core part of FFmpeg.wasm where we transpile C/C++ code of FFmpeg to JavaScript/WebAssembly code. It is still very experimental (and slow), but shows the possibilities of using FFmpeg purely in the browser.
FFmpeg wasm 사용법
공식문서가 제공하는 예시는 바닐라 js, react의 기준으로 있습니다.
다만 이를 바로 활용하기엔 Next.js와 다른점이 있고 이를 간단히 살펴보겠습니다.
npm install @ffmpeg/ffmpeg @ffmpeg/core
빠르게 설치하고 코드를 살펴보겠습니다.
const { createFFmpeg, fetchFile } = (await import("@ffmpeg/ffmpeg")).default;
const ffmpeg = createFFmpeg({
mainName: "main",
log: true,
corePath: "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js",
});
await ffmpeg.load();
ffmpeg.setProgress(({ ratio }) => {
console.log(ratio);
});
ffmpeg.FS("writeFile", "test.wav", await fetchFile(wavFile));
await ffmpeg.run("-i", "test.wav", "test.mp3");
const mp3Data = ffmpeg.FS("readFile", "test.mp3");
saveAs(
URL.createObjectURL(new Blob([mp3Data.buffer], { type: "audio/mp3" })),
"Mixed.mp3"
);
ffmpeg의 인스턴스를 만들고 이를 통해 스크립트를 돌리는 것입니다. 각각의 명령어를 여기서 다루진 않겠습니다.
전체 로직을 요약하자면 아래와 같습니다.
- 외부에서 전달한 wavFile올 test.wav를 내부적으로 만든다.
- test.wav를 test.mp3로 변환한다.
- test.mp3 파일을 읽어 Blob으로 만든다.
- Blob을 file-saver를 이용해 유저에게 제공한다.
에러 처리
React 예시 코드를 통해 Next 프로젝트에서 사용하면 다음과 같은 에러를 만날 수 있습니다.
cors에러중에 하나인데 ffmpeg.core의 모듈을 가져오기 못하기에 발생한 이슈입니다.
해결하기 위해서 corePath에 ffmpec-core의 npm 주소를 넣어서 브라우저단에서 직접 가져오도록 변경합니다.
corePath: "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js"
그 다음 에러입니다.
ffmpeg 코드를 살펴보니 mainName이 없어서 발생한 이슈로 보입니다.
최종적으로 생성하는 ffmpeg은 아래와 같은 모습을 취하게 됩니다.
const ffmpeg = createFFmpeg({
mainName: "main",
corePath: "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js",
});
최종형태
export const convertAudioBufferToMp3ByFFmpeg = async (
data: AudioBuffer,
onProgress?: (ratio: number) => void
) => {
const wavFile = URL.createObjectURL(audiobufferToWaveBlob(data));
const { createFFmpeg, fetchFile } = (await import("@ffmpeg/ffmpeg")).default;
const ffmpeg = createFFmpeg({
mainName: "main",
log: true,
corePath: "https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js",
});
await ffmpeg.load();
ffmpeg.setProgress(({ ratio }) => {
onProgress(Math.floor(ratio * 100));
});
ffmpeg.FS("writeFile", "test.wav", await fetchFile(wavFile));
await ffmpeg.run("-i", "test.wav", "test.mp3");
const mp3Data = ffmpeg.FS("readFile", "test.mp3");
return new Blob([mp3Data.buffer], { type: "audio/mp3" });
};
그리고 실제 사용하는 곳에선 아래처럼 mp3Blob만들고 다시 file-saver로 다운로드하면 완료입니다.
/* ... skip */
const mp3Blob = await convertAudioBufferToMp3ByFFmpeg(
audioBuffer,
changeMixedProgress
);
saveAs(URL.createObjectURL(mp3Blob), "Mixed.mp3");
/* ... skip */
성능
이제 구현이 완료되었으니 성능측정을 진행해봤습니다.
음원 | 노래 원본 길이 | 트랙수 | 기존(audio-encoder) | 개선(FFmpeg.wasm) |
아이유 라일락 | 1분 | 3 | 46722.56 ms | 2947.83 ms |
아이유 라일락 | 1분 | 6 | 47229.04 ms | 2870.90 ms |
아이유 라일락 | 4분 39초 | 3 | 279521.36 ms | 8597.59 ms |
아이브 러브다이브 | 1분 | 6 | 49939.08 ms | 2752.88 ms |
라이브러리를 dynamic import를 하고 프로세스를 진행하는데도 46초 -> 3초, 279초 -> 8초로 15~30배의 성능향상이 이루졌습니다.
압도적인 빠름입니다. 이정도면 도입한 보람이 있습니다.
Web worker 더하기
여기에서 끝낼수는 없습니다. FFmpeg를 사용하다보면 곧 치명적인 문제가 보입니다.
3초, 8초로 줄었지만 그동안은 자바스크립트가 FFmpeg를 처리하기 위해 다른 작업을 내팽겨치는 모습을 보입니다. (UI Block)
그래서 여기에 Webworker를 도입해보려고 합니다.
Web worker와 Service worker의 차이점에 대해 간단히 알아보고 가도록 하겠습니다.
- Web worker : UI block를 피하기 위한 무거운 연산을 주로 담당한다.
- Service worker : 백그라운드 작업, 캐싱, 오프라인, 네트워크 프록시 같은 작업을 담당한다.
Service worker의 활용 예시로는 백엔드와 독립하기 위해 사용하는 Mooking Service Worker(MSW)가 있습니다.
추가로 Web worker는 현재 Tab과 생명주기를 공유하는 반면 서비스 워크의 생명주기는 독립적입니다.
(TMI : 핸디는 이전에 서비스워커의 오프라인캐시로 인해 큰 낭패를 본적이 있다.)
여기서 Next.js에 web worker롤 도입하는 내용까지 다루긴 너무 길어지니 이건 별도의 글로 다뤄보겠습니다.
Progress의 ratio가 변하는 것을 보아하니 UI Block이 안되고 잘 돌아가고 있습니다.
왼쪽의 콘솔창은 FFmpeg의 옵션중에 log을 true하면 되는 나오는 메시지입니다.
데모
wav -> mp3로 올리는 기본 데모를 준비해봤습니다.
마무리
여기까지가 Web Assembly와 Web Worker 를 활용해 분리된 음원을 하나로 모으는 기능을 제공한 경험이었습니다.
처음 프론트엔드 개발자로 목표를 잡았을때,
많은 개발자분들이 프론트는 백엔드에서 주는 JSON 상하차에 불과하다라는 말을 많이 들었습니다.
하지만 이렇게 백엔드에서 하던 작업이 가능한 프론트엔드라니 설렙니다.
적극적으로 다양한 기술을 도입하여 발전하고 있는 프론트엔드라니, 참 기쁩니다.
끝.
'개발 > Next.js' 카테고리의 다른 글
[Next.js] NextAuth를 활용한 우아한 유저 관리 (5) | 2024.02.25 |
---|---|
[Next.js] NextAuth와 Prisma로 인증 기능 구현하기 (2) | 2023.11.27 |
[Next] 안정감있는 서비스를 위하여 (Sentry) (1) | 2023.01.12 |
[Next] localstorage를 사용하는 방법 #1 (4) | 2022.01.28 |
[Next] Tailwind CSS 도입 및 세팅(feat.Next 12) (0) | 2022.01.05 |
댓글