上一節我們已經介紹了FFmpeg在Net Core中的簡單應用,這一節我們將根據之前的功能需求和解決方案,進行項目的詳細設計工作。 畫個流程圖 先闡述一下流程,如下圖: 整個流程其實非常簡單,客戶端(無論桌面軟體、還是原生APP、還是HTML網頁)通過一個統一的介面進行調用,我們這裡定義這個介面名稱 ...
上一節我們已經介紹了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個請求出現了運行時錯誤...
這樣的問題很嚴重,就算功能實現了也決不能部署到生產環境中,也是筆者開闢這個系列的主要解決目標。也許你有更好的思路或者建議,歡迎大家一起