短影片配音原來如此簡單

2J發表於2024-04-07

沒有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 估計很快會成為大部分的程式設計師了必修課了。

有興趣的同學可以掃碼體驗下小程式。

小程式名稱 :智慧配音實用工具;

小程式二維碼 :

短影片配音原來如此簡單

相關文章