
上一节我们已经介绍了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 }
View Code
本节总结
先画个图:

画的比较简单:-)
当客户端请求该接口的时候,逻辑服务器收到请求,并通过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个请求出现了运行时错误...
这样的问题很严重,就算功能实现了也决不能部署到生产环境中,也是笔者开辟这个系列的主要解决目标。也许你有更好的思路或者建议,欢迎大家一起讨论。下一节开始介绍笔者的思路。
感谢阅读