沒有Ai不會寫程式碼了
前兩天淘寶購買的IDEA copilot外掛的賬號不能用,沒有Ai的加持感覺不會寫程式碼了。於是啟用了塵封好久的通義靈碼,也可能是用法不對,總感覺沒有 copilot智慧,畢竟廖勝於無嘛... 看著又在一行行自動生成的程式碼,陷入了沉思:我們是Ai的工具,還是Ai是我們工具。最近密集的面試過程中,發現大部分人也沒在用,甚至都沒聽過這樣的工具。《勸學》中有云:君子生非異也,善假於物。可能,對我這樣普普通通的程式設計師而言,三位一體全方位擁抱,學習,改造這些工具方是良策。前一篇文章《短影片文案提取的簡單實現》提到的文案提取功能,其實也是借住一些工具簡單實現了,今天再來聊一聊短影片配音的簡單實現。
探索配音實現
一開始看輕抖小程式上的配音功能,有停頓,有多音字,有語速等配置,頓時感覺挺有意思的。10前年做外賣配送系統時,為了方便提醒配送員搶單,用科大訊飛的TTS實現了訂單語音播報,但只是簡單的朗讀而已。摸索了一番後,瞭解騰訊雲已經有相關TTS介面了,看到騰訊雲已經提供的能力時,我感覺基本就是呼叫一個介面就基本ok了, 隱約看到了我自己在小程式上實現了智慧配音功能。事實證明,真是紙上得來終覺淺,絕知此事要躬行。
語音合同的核心介面比較簡單,就兩介面
- 基礎合成(156字以內) - 同步返回
- 長語音合成(10萬字以內)- 非同步返回
程式碼實現上使用策略模式處理不同字數的場景,使用Spring Event 統一同步與非同步處理邏輯,音訊檔案上傳到cos方便下載,前端使用setTimeout 輪詢查詢,核心類圖如下:
有了通義靈碼的輔助,三下五除二,便以迅雷不及掩耳之勢就寫好了基礎程式碼。這裡貼下基礎合成語音的程式碼,長語音合成就是加了一個回撥url的地址,返回的是任務id, 透過回撥拿到語音檔案的臨時地址。
/** * @Author: JJ * @CreateTime: 2023-11-21 09:49 * @Description: 騰訊雲tts - 一句話介面(150字以下) */ @Component @Slf4j public class SentenceTtsProcessor implements TtsProcessor { private static Credential cred = new Credential(AppConstant.Tencent.asrSecretId, AppConstant.Tencent.asrSecretKey); /** * @param complexAudioReq * @description: tts * @author: JJ * @date: 11/21/23 09:48 * @param: [bytes] * @return: java.lang.String */ @Override public TtsRes run(ComplexAudioReq complexAudioReq) { String reqId = complexAudioReq.getRequestId(); //如果為這。生成uuid if (Strings.isBlank(reqId)){ reqId = UUID.randomUUID().toString(); } log.info("tts - 基礎合成 {}", reqId); // 例項化一個http選項,可選的,沒有特殊需求可以跳過 HttpProfile httpProfile = new HttpProfile(); httpProfile.setEndpoint("tts.tencentcloudapi.com"); // 例項化一個client選項,可選的,沒有特殊需求可以跳過 ClientProfile clientProfile = new ClientProfile(); clientProfile.setHttpProfile(httpProfile); // 例項化要請求產品的client物件,clientProfile是可選的 TtsClient client = new TtsClient(cred, "ap-shanghai", clientProfile); // 例項化一個請求物件,每個介面都會對應一個request物件 TextToVoiceRequest req = new TextToVoiceRequest(); req.setText(complexAudioReq.getTtsText()); req.setSessionId(reqId); req.setVolume(complexAudioReq.getVolume().floatValue()); req.setSpeed(complexAudioReq.getSpeed().floatValue()); req.setProjectId(88L); req.setModelType(1L); req.setVoiceType(complexAudioReq.getVoiceTypeId()); req.setPrimaryLanguage(1L); req.setEnableSubtitle(false); req.setEmotionCategory(EmotionMap.getEmotion(complexAudioReq.getEmotionCategory())); req.setEmotionIntensity(complexAudioReq.getEmotionIntensity()); try { // 返回的resp是一個TextToVoiceResponse的例項,與請求物件對應 TextToVoiceResponse resp = client.TextToVoice(req); log.info("tts - 基礎合成完成 SessionId={},req={}", reqId, resp.getRequestId()); TtsRes ttsRes = TtsRes.builder() .ttsType(TtsTypeEnum.SENTENCE.code()) .requestId(resp.getRequestId()) .data(resp.getAudio()) .build(); return ttsRes; } catch (TencentCloudSDKException e) { log.error("一句話tts失敗:{}",e); throw new BusinessException(SENTENCE_TTS_ERROR.code(), SENTENCE_TTS_ERROR.desc()); } } /** * @param req * @description: filter 根據引數選 * @author: JJ * @date: 3/3/24 18:54 * @param: * @return: */ @Override public Boolean filter(ComplexAudioReq req) { // 字數小於150 if (req.getTtsTextLength() < AppConstant.Tencent.Sentence_TTS_Max_Word_Count){ return true; } return false; } }
收到合成成功的回撥後,傳送事件,監聽器非同步處理。
//傳送非同步事件,上傳cos AudioUploadCosEvent uploadCosEvent = AudioUploadCosEvent.builder() .eventTime(System.currentTimeMillis() / 1000) .recordId(usageRecordEntity.getId()) .remote(true) .dataUrl(req.getResultUrl()).build(); applicationContext.publishEvent(uploadCosEvent);
事件監聽核心邏輯就是上傳cos,並修改合成記錄狀態,程式碼非常簡單,大致如下:
/** * 音訊非同步上傳cos * @param event */ @Async @EventListener public void audioUploadCos(AudioUploadCosEvent event) { // 上傳cos InputStream inputStream = null; //根據是否遠端走不同的邏輯 if (event.isRemote()){ //跟url 下載 生成inputStream log.info("開始下載音訊{} ", updateModel.getId()); MediaDownloadReq videoReq = new MediaDownloadReq(); videoReq.setUrl(event.getDataUrl()); videoReq.setTargetFileSuffix("wav"); inputStream = mediaDownloader.run(videoReq); }else { byte[] decodedBytes = Base64.decode(event.getData()); inputStream = new ByteArrayInputStream(decodedBytes); } //上傳音訊到cos String yyyyMM = DateUtils.dateFormatDateTime(new Date(), DateUtils.formatyyyyMM); String path = "/lp/audio/"+yyyyMM+"/"+updateModel.getId()+".wav"; OssUploadResponse ossUploadResponse = OSSFactory.build().upload(inputStream, path); // 關閉InputStream inputStream.close(); log.info("上傳音訊到cos完成{}", ossUploadResponse.getUrl()); // 修改記錄狀態 }
原來還在半山腰
最近在搞2024團隊規劃,Boss希望我們能預估到大致人日,之前也有過這樣的預估實際上每次都預估偏差都不小,畢竟人日可能都在細節上。最後退而求其次,預估下兩個月的人日,沒有PRD,沒有技術方案大機率預估的人日可能只會在半山腰,就如同配音的功能寫到這裡,我以為已經“會當臨絕頂,一覽眾山小了”,哪知道一山還有一山高。
第一難就是多音字,要得到正確的發音,就需要明確指出發音與聲調。比如對於騰訊雲的語音合成介面支援 SSML 標記語言,比如我們需要讓“長”發音為“zhang”,就需要做這樣的標記。
<speak><phoneme alphabet="py" ph="zhang3">長</phoneme></speak>
對於後端而言,這個是簡單的,透過pinyin4j可以快速找出一段文字中的多音字及其所有讀音,幾行程式碼就解決了。
/** * 多音字檢測 * @param req * @return * @throws Exception */ public List<PolyphoneVo> run(PolyphoneQuery req) { //遍歷字串,找出多音字 char[] chars = req.getTtsText().toCharArray(); List<PolyphoneVo> polyphoneVoList = new ArrayList<>(); Set<String> polyphoneWordSet = new HashSet<>(); for (char c : chars) { if((c >= 0x4e00)&&(c <= 0x9fbb)) { String[] pinyinList = PinyinHelper.toHanyuPinyinStringArray(c); if (pinyinList != null && pinyinList.length > 1 && !polyphoneWordSet.contains(c+"")) { PolyphoneVo polyphoneVo = new PolyphoneVo(); polyphoneVo.setWord(c+""); polyphoneVo.setReadList(Arrays.asList(pinyinList)); polyphoneVoList.add(polyphoneVo); polyphoneWordSet.add(c+""); } } } return polyphoneVoList; }
前端難點在於如何讓多音字可以點選以及點選後的互動,以及SSML 標記語言替換。給大家來兩張效果圖,有興趣的可以腦補下前端實現。主要兩個地方注意下:顯示文字的資料結構和正規表示式。
行文至此,停頓有了,發音正常了,音訊檔案也有了,播放音訊也正常了,只是進度條著實費了一些神,官方沒有進度條的實現,最後用 van-slider 模擬實現了。唯一bug是無法獲取正確的音時長,正當一籌莫展時,又有一個問題出現了:mp3格式無法正常在小程式中儲存檔案,官方文件中的描述確實只支援mp4檔案。一番鬥爭後,覺得這個問題更為重要,於是又開始了摸索。
音訊轉影片的意外收穫
mp3轉成mp4,還是javaCV,有了之前的影片中分離音訊的經歷,這次就順利多了。唯一的問題就是:影片檔案的禎沒有內容,所以是一片黑。於是想著用一張固定的圖片做為禎內容。中間最麻煩的是音訊與禎如何同步。最後還是與GPT4進行了一次多輪對話才完美瞭解決了。 用小程式二維碼圖做為預設的禎,效果如下圖。在轉換過程中,又根據FFmpegFrameGrabber.getLengthInTime 獲取到了音訊進長,順道解決了前面無法解決的問題。真可謂是“無心插柳柳成蔭”。程式碼與上一篇文章中的文案提取的程式碼基本雷同,就不貼了。
寫在最後
寫到這裡,坑算是基本都趟過了。雖說實際所花的時間早已遠超之前的計劃了,好在出來效果還不錯,也順道補充了一些基本見識,也是不錯了。再回來Ai輔助程式設計的話題,Ai知道的東西很多,會越來越多,如果提升個人思維能力,如何利用AI 估計很快會成為大部分的程式設計師了必修課了。
有興趣的同學可以掃碼體驗下小程式。
小程式名稱 :智慧配音實用工具;
小程式二維碼 :