上一节我们已经介绍了FFmpeg在Net Core中的简单应用,这一节我们将根据之前的功能需求和解决方案,进行项目的详细设计工作。
画个流程图
先阐述一下流程,如下图:
整个流程其实非常简单,客户端(无论桌面软件、还是原生APP、还是HTML网页)通过一个统一的接口进行调用,我们这里定义这个接口名称叫AudioSynthesisSync吧,为何名称后面还要加个同步(也是命名规范),目前来说这个接口就属于同步的,异步方式后续再一一解答。
当得到指定的两个(前景和背景)参数后,服务器去自动下载(或本地缓存)参数指定的音频文件,将这两个音频带入到ffmpeg处理程序中进行音频合成和处理(处理效果包括指定时间剪辑、淡入和淡出、拼接多个音频、合并两个音频、音量缩放,具体参数参见ffmpeg官方文档),等待音频处理完成后得到一个新的文件,将这个文件上传到指定云(笔者这边用的是阿里云OSS),上传完成后将阿里云的云文件访问地址存储并返回到请求的客户端,整个流程结束。
反观上面整个流程,其实就三个步骤的操作,下载文件,处理文件,上传文件。ok,那么我们只需要一个Controller即可,这里我们声明为MediaApiController吧。
创建默认构造函数
大家现在都码砖都喜欢用依赖注入的方式去构建某个类,笔者也不例外,依赖注入的优势很多很多,你可以使用netcore自带,asp.net core,autofac,unity等优秀的框架做依赖注入,笔者这里不做详细阐述。
笔者这里使用autofac注入了大量乱七八糟的类(说是乱七八糟,实际是笔者创建的一个easyHub的框架,日后会一一分享出来),有注释。
1 public class MediaApiController: BaseApiController 2 3 { 4 // 消息总线 5 private readonly IMsgBusService _iMsgBusService; 6 // 数据库操作工厂 7 private readonly IDataOpService _dataOpService; 8 // 缓存操作工厂 9 private readonly ICacheAsyncService _iCacheAsyncService; 10 // 宿主环境 11 private readonly IHostingEnvironment _ihostingEnvironment; 12 // 音频处理单元 13 private readonly AudioHandlerWorkUnit _audioHandlerWorkUnit; 14 15 public MediaApiController(IDataOpService iDataOpService, 16 17 ICacheAsyncService iCacheAsyncService, 18 19 IMsgBusService imsgBusService, 20 21 IHostingEnvironment iHostingEnvironment, 22 23 IDurationMath durationMath) 24 25 { 26 27 _dataOpService = iDataOpService; 28 29 _iMsgBusService = imsgBusService; 30 31 _iCacheAsyncService = iCacheAsyncService; 32 33 _ihostingEnvironment = iHostingEnvironment; 34 35 _audioHandlerWorkUnit = new AudioHandlerWorkUnit(iDataOpService: _dataOpService, 36 37 iCacheAsyncService: _iCacheAsyncService, 38 39 imsgBusService: _iMsgBusService, 40 41 iHostingEnvironment: _ihostingEnvironment, 42 43 iDurationMath: durationMath 44 45 ); 46 47 } 48 49 }
在MediaApiController构造函数中,笔者将需要使用的中间服务一股脑的全部注入到控制器中,其中iDataOpService接口实现了数据库的操作,iCacheAsyncService实现了缓存操作,imsgBusService实现了消息队列操作,iHostingEnvironment是netcore存储的宿主环境参数和变量,durationMath实现了处理耗时,AudioHandlerWorkUnit是音频处理的主要方法类。
当然,还有细心的朋友发现了,笔者这边不是继承于Controller类型,而是继承于BaseApiController类型,在net api中可没有这个类型的啊。的确,这是笔者自定义的一个类型,当然父类肯定继承于Controller下,为何笔者喜欢这样中间再多继承一层,不是多次一举吗?不是,笔者这边简单的介绍一下:
Controller的功能和特性这里不做阐述,如果我们要在netcore中实现web请求,那么必须继承于该Controller类型。笔者喜欢使用AOP编程模式进行码砖,这样能遵循开闭原则,并且便于维护,在BaseApiController中,笔者重写了OnActionExecuting,OnActionExecuted,OnActionExecutionAsync三个主要方法,便于在请求处理过程中,对出和入进行过滤、处理时间的计算、返回内容的重构等等进行统一规范。(项目源码会在日后新开的EasyHub框架中详细介绍)
创建音频信息模型
在我们创建接口之前,需要规范来回传输数据结构上面的一些信息,比如音频文件名、格式、持续时间、编码率等等一些基础信息。
因此建立一个模型叫AudioInfo
1 public class AudioInfo 2 3 { 4 5 public string filename { 6 get; 7 set; 8 } 9 10 public int nb_streams { 11 get; 12 set; 13 } 14 15 public int nb_programs { 16 get; 17 set; 18 } 19 20 public string format_name { 21 get; 22 set; 23 } 24 25 public string format_long_name { 26 get; 27 set; 28 } 29 30 public string start_time { 31 get; 32 set; 33 } 34 35 public double duration { 36 get; 37 set; 38 } 39 40 public long size { 41 get; 42 set; 43 } 44 45 public int bit_rate { 46 get; 47 set; 48 } 49 50 public int probe_score { 51 get; 52 set; 53 } 54 55 }
通过ffprobe命令获取一个音频文件后,数据实例化样本如下
[format, { "filename": "text.mp3", "nb_streams": 1, "nb_programs": 0, "format_name": "mp3", "format_long_name": "MP2/3 (MPEG audio layer 2/3)", "start_time": "0.000000", "duration": "25.568875", "size": "409102", "bit_rate": "128000", "probe_score": 51 }]
各项参数意思不用过多解释相信大家从单词上就够能明白。
创建接口
接下来我们创建一个接口,命名为AudioSynthesisSync。
1 /// <summary> 2 /// 同步合成两个音频文件并上传到阿里云 3 /// </summary> 4 /// <param name="frontFileUrl">输入文件1,一般是前景读音</param> 5 /// <param name="backgounedAudioIndex">背景音乐文件</param> 6 /// <remarks> 7 /// 背景音乐文件,需要在应用程序根目录下面创建StaticResurces文件夹 8 /// 并将数据库BackGroundAudioListModels中AudioUrl路径文件名相对应 9 /// 合成时间将受到音频时长、CPU性能严重相关而定。接口适合较短(10秒内的音频合成) 10 /// </remarks> 11 /// <returns>合成后音频文件的URL地址</returns> 12 [HttpGet] 13 [Route("AudioSynthesisSync")] 14 public JsonResult AudioSynthesisSync(string frontFileUrl, int backgounedAudioIndex) 15 { 16 if (string.IsNullOrEmpty(frontFileUrl)) 17 return new JsonResult("文件不能为空") {StatusCode = ClientStatusCode.ClientParameterError}; 18 if (frontFileUrl.Contains("https://")) 19 return new JsonResult("不支持https加密协议") {StatusCode = ClientStatusCode.ClientParameterError}; 20 if (!frontFileUrl.Contains("http://")) 21 return new JsonResult("文件必须存在于网络") {StatusCode = ClientStatusCode.ClientParameterError}; 22 23 var mixInfo = _audioHandlerWorkUnit.SynthesisAudio(frontFileUrl, backgounedAudioIndex, ""); 24 25 if (mixInfo.GetType() == typeof(JsonResult)) 26 { 27 // 存在错误的时候直接返回错误 28 return mixInfo; 29 } 30 31 return new JsonResult(new Dictionary<string, object> 32 { 33 {"web_url", mixInfo.WebUrl}, 34 { 35 "duration", new Dictionary<string, object>() 36 { 37 {"download", mixInfo.downloadDuration.GetTotalDuration()}, 38 {"synthesis", mixInfo.synthesisDuration.GetTotalDuration()}, 39 {"upload", mixInfo.uploadDuration.GetTotalDuration()} 40 } 41 }, 42 { 43 "fileInfo", new Dictionary<string, object>() 44 { 45 {"front_audio", mixInfo.synthesisAudioinfo.FrontAudioInfo}, 46 {"back_audio", mixInfo.synthesisAudioinfo.BackAudioInfo}, 47 {"synthesis_audio", mixInfo.synthesisAudioinfo.SynthesisInfo} 48 } 49 } 50 }) {StatusCode = ClientStatusCode.Ok}; 51 }
而SynthesisAudio函数的源码如下(源码过长,有兴趣的朋友可以自行实现自己想要的音频处理逻辑和效果,全然当此段为参考范本)
1 public dynamic SynthesisAudio(string frontFileUrl, int backgounedAudioIndex, string taskName) 2 { 3 // 将当前任务添加到队列列表池中,用于限制本机最大队列数量,防止单机队列过多而死机 4 CurrentQueueTask.Add(taskName); 5 6 ProcessState.CurrentAudioProcessingState = AudioProcessingState.StartHandler; 7 _iCacheAsyncService.SetDatabase(0); 8 _iCacheAsyncService.SetStringAsync(taskName, 9 JsonConvert.SerializeObject(new ProgressPrompt() 10 { 11 Remarks = ProcessState.GetState(), 12 Progress = 10 13 }), 14 TimeSpan.FromDays(KeyExpire)); 15 16 var totalDuration = new DurationMath(); 17 var synthesisDuration = new DurationMath(); 18 var downloadDuration = new DurationMath(); 19 var uploadDuration = new DurationMath(); 20 var backgroundInfo = new BackGroundAudio(); 21 22 string aliyunReturnUrl; 23 MixedInfo synthesisAudioinfo; 24 25 try 26 { 27 #region 获取前景和背景音频文件 28 29 ProcessState.CurrentAudioProcessingState = AudioProcessingState.DownloadAudio; 30 downloadDuration.Start(); 31 32 using (var r = GetFrontFileAndBackGroundAudio(frontFileUrl, backgounedAudioIndex)) 33 { 34 if (!r.IsExceptionReturn) 35 { 36 backgroundInfo = (BackGroundAudio) r.ReturnObjects; 37 38 if (backgroundInfo == null) 39 { 40 _iCacheAsyncService.SetStringAsync(taskName, 41 JsonConvert.SerializeObject(new ProgressPrompt() 42 { 43 Remarks = 44 $"InExpcetion_{DateTime.Now}_{taskName}_request_audio_index_not_found_for_{backgounedAudioIndex}", 45 Progress = 10 46 }), 47 TimeSpan.FromDays(KeyExpire)); 48 49 return new JsonResult($"请求的背景音乐索引不存在{backgounedAudioIndex}") 50 {StatusCode = ClientStatusCode.ClientParameterError}; 51 } 52 53 backgroundInfo.AudioUrl = AppDomain.CurrentDomain.BaseDirectory 54 + "StaticResources/" 55 + backgroundInfo.AudioUrl; 56 57 if (!System.IO.File.Exists(backgroundInfo.AudioUrl)) 58 { 59 _iCacheAsyncService.SetStringAsync(taskName, 60 JsonConvert.SerializeObject(new ProgressPrompt() 61 { 62 Remarks = 63 $"InExpcetion_{DateTime.Now}_{taskName}_request_audio_path_not_found_for_{backgroundInfo.AudioUrl}", 64 Progress = 100 65 }), 66 TimeSpan.FromDays(KeyExpire)); 67 68 return new JsonResult($"背景音频文件物理路径不存在{backgroundInfo.AudioUrl}") 69 {StatusCode = ClientStatusCode.ClientParameterError}; 70 } 71 } 72 else 73 { 74 _iCacheAsyncService.SetStringAsync(taskName, 75 JsonConvert.SerializeObject(new ProgressPrompt() 76 { 77 Remarks = $"InExpcetion_{DateTime.Now}_{taskName}", 78 Progress = 100 79 }), 80 TimeSpan.FromDays(KeyExpire)); 81 82 return new JsonResult($"文件获取失败,详见错误日志({r.ExceptionCode})输出") 83 {StatusCode = ClientStatusCode.ServerHandlerError}; 84 } 85 } 86 87 _iCacheAsyncService.SetStringAsync(taskName, 88 JsonConvert.SerializeObject(new ProgressPrompt() 89 { 90 Remarks = ProcessState.GetState(), 91 Progress = 30 92 }), 93 TimeSpan.FromDays(KeyExpire)); 94 95 downloadDuration.Stop(); 96 97 #endregion 98 99 #region 音频合成 100 101 ProcessState.CurrentAudioProcessingState = AudioProcessingState.SynthesisAudio; 102 synthesisDuration.Start(); 103 using (var r = GetCustomMixedTwoAudio(frontFileUrl, backgroundInfo.AudioUrl)) 104 { 105 if (!r.IsExceptionReturn) 106 { 107 synthesisAudioinfo = (MixedInfo) r.ReturnObjects; 108 } 109 else 110 { 111 _iCacheAsyncService.SetStringAsync(taskName, 112 JsonConvert.SerializeObject(new ProgressPrompt() 113 { 114 Remarks = $"InExpcetion_{DateTime.Now}_{taskName}", 115 Progress = 100 116 }), 117 TimeSpan.FromDays(KeyExpire)); 118 119 return new JsonResult($"音频合成失败,详见错误日志({r.ExceptionCode})输出") 120 { 121 StatusCode = ClientStatusCode.ServerHandlerError 122 }; 123 } 124 } 125 126 _iCacheAsyncService.SetStringAsync(taskName, 127 JsonConvert.SerializeObject(new ProgressPrompt() 128 { 129 Remarks = ProcessState.GetState(), 130 Progress = 60 131 }), 132 TimeSpan.FromDays(KeyExpire)); 133 134 synthesisDuration.Stop(); 135 136 #endregion 137 138 #region 上传到阿里云 139 140 ProcessState.CurrentAudioProcessingState = AudioProcessingState.UploadAudio; 141 uploadDuration.Start(); 142 143 using (var r = UploadTheAliyun(synthesisAudioinfo)) 144 { 145 if (!r.IsExceptionReturn) 146 { 147 aliyunReturnUrl = (string) r.ReturnObjects; 148 } 149 else 150 { 151 _iCacheAsyncService.SetStringAsync(taskName, 152 JsonConvert.SerializeObject(new ProgressPrompt() 153 { 154 Remarks = $"InExpcetion_{DateTime.Now}_{taskName}", 155 Progress = 100 156 }), 157 TimeSpan.FromDays(KeyExpire)); 158 159 return new JsonResult($"上传阿里云失败,详见错误日志({r.ExceptionCode})输出") 160 { 161 StatusCode = ClientStatusCode.ServerHandlerError 162 }; 163 } 164 } 165 166 _iCacheAsyncService.SetStringAsync(taskName, 167 JsonConvert.SerializeObject(new ProgressPrompt() 168 { 169 Remarks = ProcessState.GetState(), 170 Progress = 80 171 }), 172 TimeSpan.FromDays(KeyExpire)); 173 uploadDuration.Stop(); 174 175 #endregion 176 177 #region 存储到数据库 178 179 ProcessState.CurrentAudioProcessingState = AudioProcessingState.UpdateDatabase; 180 var dataOptionException = ""; 181 _dataOpService.DataOperatedCallBackEvent += (sender, args) => 182 { 183 if (!string.IsNullOrEmpty(args.Exceptions)) 184 { 185 _iCacheAsyncService.SetStringAsync(taskName, 186 JsonConvert.SerializeObject(new ProgressPrompt() 187 { 188 Remarks = $"InExpcetion_{DateTime.Now}_{taskName}", 189 Progress = 100 190 }), 191 TimeSpan.FromDays(KeyExpire)); 192 193 dataOptionException = args.Exceptions; 194 } 195 }; 196 197 // 将异步最终结果放入到数据库中,便于多次查询 198 var addResult = _dataOpService.AddEntity(new AudioSynthesisAsyncResult 199 { 200 HandlerResult = JsonConvert.SerializeObject(synthesisAudioinfo), 201 TaskName = taskName, 202 web_url = aliyunReturnUrl, 203 Duration = JsonConvert.SerializeObject(new Dictionary<string, object>() 204 { 205 {"download", downloadDuration.GetTotalDuration()}, 206 {"synthesis", synthesisDuration.GetTotalDuration()}, 207 {"upload", uploadDuration.GetTotalDuration()} 208 }) 209 }, new MediaContext(new DatabaseConfig())).Result; 210 211 if (!string.IsNullOrEmpty(dataOptionException)) 212 { 213 _iCacheAsyncService.SetStringAsync(taskName, 214 JsonConvert.SerializeObject(new ProgressPrompt() 215 { 216 Remarks = $"InExpcetion_{DateTime.Now}_{taskName}", 217 Progress = 100 218 }), 219 TimeSpan.FromDays(KeyExpire)); 220 221 return new JsonResult("数据库操作失败,详见错误日志(Error)输出") 222 { 223 StatusCode = ClientStatusCode.ServerHandlerError 224 }; 225 } 226 227 #endregion 228 229 if (addResult > 0) 230 { 231 ProcessState.CurrentAudioProcessingState = AudioProcessingState.InCompleted; 232 _iCacheAsyncService.SetStringAsync(taskName, 233 JsonConvert.SerializeObject(new ProgressPrompt() 234 { 235 Remarks = ProcessState.GetState(), 236 Progress = 100 237 }), 238 TimeSpan.FromDays(KeyExpire)); 239 } 240 241 // 屏蔽返回字符串中详细的IO地址 242 synthesisAudioinfo.FrontAudioInfo.filename = 243 Path.GetFileName(synthesisAudioinfo.FrontAudioInfo.filename); 244 synthesisAudioinfo.BackAudioInfo.filename = 245 Path.GetFileName(synthesisAudioinfo.BackAudioInfo.filename); 246 synthesisAudioinfo.SynthesisInfo.filename = 247 Path.GetFileName(synthesisAudioinfo.SynthesisInfo.filename); 248 totalDuration.Stop(); 249 250 CurrentQueueTask.Remove(taskName); 251 ProcessState.CurrentAudioProcessingState = AudioProcessingState.EmptyHandler; 252 return new AudioSynthesisSyncResult 253 { 254 WebUrl = aliyunReturnUrl, 255 downloadDuration = downloadDuration, 256 synthesisDuration = synthesisDuration, 257 uploadDuration = uploadDuration, 258 synthesisAudioinfo = synthesisAudioinfo 259 }; 260 } 261 catch (Exception e) 262 { 263 Console.WriteLine(e); 264 CurrentQueueTask.Remove(taskName); 265 return e; 266 } 267 }
本节总结
先画个图:
画的比较简单:-)
当客户端请求该接口的时候,逻辑服务器收到请求,并通过ffmpeg进行处理,当ffmpeg处理完成后,将新的文件上传到云OSS,通过逻辑服务器再将云URL将返回给请求客户端,很简单。
注意问题
按照如上的代码,很快便实现了这个项目的需求,可能最多也就三天时间吧(包括测试)。心里暗示,嗯,功能我完成了,可以向上汇报了。
可细心的朋友、或有过多年WEB服务器工作经验的朋友就会发现一个至关重要的问题:这个功能接口的TPS非常的慢。为何这么说,单机的性能是固定的,而用户所录制的音频时长却不是固定的,假如就算限制为3分钟的录音(微信最长才60秒),那么服务器会面临1s-180s区间不同的处理耗时,即使用上目前最好的多路CPU,恐怕也不可能在180s的处理需求中达到毫秒级的处理响应吧。
我们这样来假设一个场景(嗯,有点机器学习的味道o(∩_∩)o),用户录音为10秒,背景声音为15秒(前后加点听觉缓冲:渐入和渐出),笔者多次测试过,包括用上E-2680 v2版的CPU,忽略下载和上传时间,FFMPEG处理时间仍然需要3秒(FFMPEG的处理模型是如何工作的我不清楚,加入-threads参数也无济于事)。因此可得到这样一个公式:
如果这台服务器大部分时间都耗在处理一个任务上,那么出现多个请求都在这个接口上呢,那么时间将会更加的长,笔者试过一次并发10个请求(对于处理时间以秒为单位的状况,10个并发对单机是很吓人的),结果最后一个请求结果得到的时间是48秒,哈哈,客户端肯定是无法等待这么长的时间的,而且这个10个请求中,有2个请求出现了运行时错误...
这样的问题很严重,就算功能实现了也决不能部署到生产环境中,也是笔者开辟这个系列的主要解决目标。也许你有更好的思路或者建议,欢迎大家一起讨论。下一节开始介绍笔者的思路。
感谢阅读