当前位置: 首页 > Web前端 > HTML5

实践!实现纯前端下的音频剪辑处理

时间:2023-04-05 00:41:14 HTML5

实践!实现纯前端下的音频剪辑处理。本来笔者是打算把这个工作交给服务端来完成的,但是考虑到,其实不管是前端还是后端,所做的工作都是类似的,交给服务端需要一个上传和下载音频的附加过程。这样不仅增加了服务器的压力,而且还有网络流量的开销,于是萌生了一个想法:为什么音频处理不能交由前端来做呢?于是在笔者半探索半实践之下,产生了这篇文章。话不多说,先上仓库地址吧。这是一个开箱即用的前端音频编辑sdk(点进去后不妨加星标)。ffmpegffmpeg是一个非常核心的前端音频处理模块。当然,不仅仅是前端,ffmpge作为业界成熟完整的提供音视频录制、转换、流化的解决方案,也被应用在服务端、APP应用等各种场景。关于ffmpeg的介绍大家可以自行google,这里就不多说了。由于ffmpeg在处理过程中需要大量的计算,所以不可能直接在前端页面运行,因为我们需要单独开一个webworker,让它在worker本身运行,不阻塞页面交互。好在万能的github上有开发者提供了ffmpge.js和worker版本,可以直接使用。于是我们就有了一个大概的思路:获取音频文件后,解码后发送给worker进行计算处理,将处理结果作为事件返回,这样我们就可以对音频做任何想做的事情了:)必要的精彩旅程开始前的工作需要提前声明,因为笔者的项目需求只需要处理.mp3格式,所以下面的代码示例和仓库地址涉及的代码主要是针对mp3的,当然,不管是哪种格式,思路差不多。创建worker的方法很简单。只需创建一个新的。注意,由于同源策略的限制,要使worker正常工作,必须与父页面同源。因为这不是重点,所以跳过functioncreateWorker(workerPath:string){constworker=newWorker(workerPath);returnworker;}postMessagetopromise如果你仔细看ffmpeg。Done等,如果直接为这些事件添加回调函数,在回调函数中不容易维护区分和处理一个接一个的音频结果。就我个人而言,我更喜欢把它变成一个promise:){case"stdout":console.log("workerstdout:",event.data.data);break;case"start":console.log("worker收到你的命令并开始工作:)");休息;案例“完成”:worker.removeEventListener(“消息”,successHandler);解决(事件);休息;默认值:中断;}};//异常捕获constfailHandler=function(error){worker.removeEventListener("error",failHandler);拒绝(错误);};worker.addEventListener("消息",successHandler);worker.addEventListener("error",failHandler);postInfo&&worker.postMessage(postInfo);});}通过这个转换,我们可以将一个postMessage请求转化为一个promise进行处理,这样更容易扩展空间,在audio、blob和arrayBuffer之间转换ffmpeg-worker需要的数据格式是arrayBuffer,一般我们可以直接使用的要么是音频文件对象blob,要么是音频元素对象audio,甚至是链接url,所以这些格式的转换是非常必要的:audiotoarrayBuffer函数audioToBlob(audio){consturl=audio.src;if(url){returnaxios({url,method:'get',responseType:'arraybuffer',}).then(res=>res.data);}else{returnPromise.resolve(null);}}作者想到的把audio转blob的方法是发起ajax请求,设置请求类型为arraybuffer,然后获取arrayBuffer.blob为arrayBuffer,很简单,直接用FileReader提取blob内容函数blobToArrayBuffer(blob){returnnewPromise(resolve=>{constfileReader=newFileReader();fileReader.onload=function(){resolve(fileReader.result);};fileReader.readAsArrayBuffer(blob);});}arrayBuffer以blob使用创建blob文件functionaudioBufferToBlob(arrayBuffer){constfile=newFile([arrayBuffer],'test.mp3',{type:'audio/mp3',});returnfile;}blobtoaudioblobtoaudio很简单,js提供了一个原生的API——URL.createObjectURL,通过它我们可以将blob转化为本地可访问的链接,用于播放函数blobToA音频(blob){consturl=URL.createObjectURL(blob);returnnewAudio(url);}接下来进入正题音频裁剪——所谓clip的裁剪是指根据给定的起止时间点,提取给定音频的内容,形成新的音频,首先添加代码:classSdk{end="end";//其他代码.../***根据指定的时间位置剪切一个传入的音频blob*@paramoriginBlob待处理的音频*@paramstartSecond开始剪切时间(秒)*@paramendSecond结束剪切时间(秒)*/clip=async(originBlob,startSecond,endSecond)=>{constss=startSecond;//获取要裁剪的时长,如果没有传endSecond,默认裁剪到最后constd=isNumber(endSecond)?endSecond-startSecond:this.end;//将blob转换为可处理的arrayBufferconstoriginAb=awaitblobToArrayBuffer(originBlob);让resultArrBuf;//获取发送给ffmpge-worker的命令并发送给worker,等待其裁剪完成if(d===this.end){resultArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,ss))).data.data.MEMFS[0].data;}else{resultArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,ss,d))).data.data.MEMFS[0].data;}//将worker处理后的arrayBuffer包装成blob返回returnaudioBufferToBlob(resultArrBuf);};}我们定义了这个接口的三个参数:要剪辑的音频块,剪辑的开始和结束时间点。值得注意的是,这里的getClipCommand函数负责传入arrayBuffer,将其打包成ffmpeg-worker约定的数据格式/***根据ffmpeg文档的要求,将裁剪后的数据转换成指定的格式*@paramarrayBuffer要处理的音频缓冲区*@paramst开始剪辑的时间点(秒)*@paramduration剪辑持续时间*/functiongetClipCommand(arrayBuffer,st,duration){return{type:"run",arguments:`-ss${st}-iinput.mp3${持续时间?`-t${duration}`:""}-acodeccopyoutput.mp3`.split(""),MEMFS:[{data:newUint8Array(arrayBuffer),name:"input.mp3"}]};}Multi-audiosynthesis——concatmulti-audiosynthesis很容易理解,就是把多个audio合并成一个audioclasssdk{//othercode.../***根据指定时间裁剪一个传入的audioblobposition*@paramblobs是要处理的音频blob数组*/concat=asyncblobs=>{constarrBufs=[];for(leti=0;i({data:newUint8Array(arrayBuffer),name:`input${index}.mp3`,}));//创建一个txt文本来告诉ffmpeg我们需要合并哪些音频文件(类似于这些文件表的映射)consttxtContent=[files.map(f=>`file'${f.name}'`).加入('\n')];consttxtBlob=newBlob(txtContent,{type:'text/txt'});constfileArrayBuffer=awaitblobToArrayBuffer(txtBlob);//将txt文件推送到要发送给ffmpeg-worker的文件列表中files.push({data:newUint8Array(fileArrayBuffer),name:'filelist.txt',});return{type:'run',arguments:`-fconcat-ifilelist.txt-ccopyoutput.mp3`.split(''),MEMFS:files,};}在上面的代码中,不同于裁剪操作,要操作的音频对象不是一个,而是多个,所以需要创建一张“映射表”,告诉ffmpeg-worker哪些音频需要合并,合并的顺序。音频剪辑替换——splice有点像clip的升级版,我们从指定位置删除音频A,在这里插入音频B:classSdk{end="end";//其他代码.../***在指定位置用另一端的音频替换一个音频blob*@paramoriginBlob待处理的音频blob*@paramstartSecond开始时间点(秒)*@paramendSecond结束时间点(秒)*@paraminsertBlob是替换的音频blob*/splice=async(originBlob,startSecond,endSecond,insertBlob)=>{constss=startSecond;constes=isNumber(endSecond)?endSecond:this.end;//onlydeleteifinsertBlobdoesnotexist音频的指定内容insertBlob=insertBlob?insertBlob:endSecond&&!isNumber(endSecond)?第二个:空;constoriginAb=awaitblobToArrayBuffer(originBlob);让leftSideArrBuf,rightSideArrBuf;//先在指定位置剪切分割音频if(ss===0&&es===this.end){//全部剪切returnnull;}elseif(ss===0){//开始裁剪rightSideArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,es))).data.data.MEMFS[0].data;}否则如果(小号s!==0&&es===this.end){//剪辑到最后leftSideArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,0,ss))).data.data.MEMFS[0].数据;}else{//部分裁剪leftSideArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,0,ss))).data.data.MEMFS[0].data;rightSideArrBuf=(awaitpmToPromise(this.worker,getClipCommand(originAb,es))).data.data.MEMFS[0].data;}//再次合并多个音频constarrBufs=[];leftSideArrBuf&&arrBufs.push(leftSideArrBuf);insertBlob&&arrBufs.push(awaitblobToArrayBuffer(insertBlob));rightSideArrBuf&&arrBufs.push(rightSideArrBuf);constcombinedResult=awaitpmToPromise(this.worker,awaitgetCombineCommand(arrBufs));返回audioBufferToBlob(combindResult.data.data.MEMFS)[0]。;};}上面的代码有点类似于clip和concat的复合使用至此,我们的需求就基本满足了。在工人的帮助下,前端也可以自己处理音频。是不是很美?上面的代码只是为了更好的解释,所以做了一些简化。有兴趣的童鞋可以直接源码,欢迎交流拍砖:)