短视频配音原来如此简单
没有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
@Slf4jpublic class SentenceTtsProcessor implementsTtsProcessor {private static Credential cred = newCredential(AppConstant.Tencent.asrSecretId, AppConstant.Tencent.asrSecretKey);/***@paramcomplexAudioReq
* @description: tts
*@author: JJ
* @date: 11/21/23 09:48
*@param: [bytes]
*@return: java.lang.String*/@OverridepublicTtsRes run(ComplexAudioReq complexAudioReq) {
String reqId=complexAudioReq.getRequestId();//如果为这。生成uuid if(Strings.isBlank(reqId)){
reqId=UUID.randomUUID().toString();
}
log.info("tts - 基础合成 {}", reqId);//实例化一个http选项,可选的,没有特殊需求可以跳过 HttpProfile httpProfile = newHttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");//实例化一个client选项,可选的,没有特殊需求可以跳过 ClientProfile clientProfile = newClientProfile();
clientProfile.setHttpProfile(httpProfile);//实例化要请求产品的client对象,clientProfile是可选的 TtsClient client = new TtsClient(cred, "ap-shanghai", clientProfile);//实例化一个请求对象,每个接口都会对应一个request对象 TextToVoiceRequest req = newTextToVoiceRequest();
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();returnttsRes;
}catch(TencentCloudSDKException e) {
log.error("一句话tts失败:{}",e);throw newBusinessException(SENTENCE_TTS_ERROR.code(), SENTENCE_TTS_ERROR.desc());
}
}/***@paramreq
* @description: filter 根据参数选
*@author: JJ
* @date: 3/3/24 18:54
*@param:
*@return:*/@OverridepublicBoolean 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
*@paramevent*/@Async
@EventListenerpublic voidaudioUploadCos(AudioUploadCosEvent event) {//上传cos InputStream inputStream = null;//根据是否远程走不同的逻辑 if(event.isRemote()){//跟url 下载 生成inputStream log.info("开始下载音频{} ", updateModel.getId());
MediaDownloadReq videoReq= newMediaDownloadReq();
videoReq.setUrl(event.getDataUrl());
videoReq.setTargetFileSuffix("wav");
inputStream=mediaDownloader.run(videoReq);
}else{byte[] decodedBytes =Base64.decode(event.getData());
inputStream= newByteArrayInputStream(decodedBytes);
}//上传音频到cos String yyyyMM = DateUtils.dateFormatDateTime(newDate(), 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可以快速找出一段文本中的多音字及其所有读音,几行代码就解决了。
/*** 多音字检测
*@paramreq
*@return*@throwsException*/ public List<PolyphoneVo>run(PolyphoneQuery req) {//遍历字符串,找出多音字 char[] chars =req.getTtsText().toCharArray();
List<PolyphoneVo> polyphoneVoList = new ArrayList<>();
Set<String> polyphoneWordSet = new HashSet<>();for (charc : chars) {if((c >= 0x4e00)&&(c <= 0x9fbb)) {
String[] pinyinList=PinyinHelper.toHanyuPinyinStringArray(c);if (pinyinList != null && pinyinList.length > 1 && !polyphoneWordSet.contains(c+"")) {
PolyphoneVo polyphoneVo= newPolyphoneVo();
polyphoneVo.setWord(c+"");
polyphoneVo.setReadList(Arrays.asList(pinyinList));
polyphoneVoList.add(polyphoneVo);
polyphoneWordSet.add(c+"");
}
}
}returnpolyphoneVoList;
}
前端难点在于如何让多音字可以点击以及点击后的交互,以及SSML 标记语言替换。给大家来两张效果图,有兴趣的可以脑补下前端实现。主要两个地方注意下:显示文本的数据结构和正则表达式。
行文至此,停顿有了,发音正常了,音频文件也有了,播放音频也正常了,只是进度条着实费了一些神,官方没有进度条的实现,最后用 van-slider 模拟实现了。唯一bug是无法获取正确的音时长,正当一筹莫展时,又有一个问题出现了:mp3格式无法正常在小程序中保存文件,官方文档中的描述确实只支持mp4文件。一番斗争后,觉得这个问题更为重要,于是又开始了摸索。
音频转视频的意外收获
mp3转成mp4,还是javaCV,有了之前的视频中分离音频的经历,这次就顺利多了。唯一的问题就是:视频文件的祯没有内容,所以是一片黑。于是想着用一张固定的图片做为祯内容。中间最麻烦的是音频与祯如何同步。最后还是与GPT4进行了一次多轮对话才完美了解决了。 用小程序二维码图做为默认的祯,效果如下图。在转换过程中,又根据FFmpegFrameGrabber.getLengthInTime 获取到了音频进长,顺道解决了前面无法解决的问题。真可谓是“无心插柳柳成荫”。代码与上一篇文章中的文案提取的代码基本雷同,就不贴了。
写在最后
写到这里,坑算是基本都趟过了。虽说实际所花的时间早已远超之前的计划了,好在出来效果还不错,也顺道补充了一些基本见识,也是不错了。再回来Ai辅助编程的话题,Ai知道的东西很多,会越来越多,如果提升个人思维能力,如何利用AI 估计很快会成为大部分的程序员了必修课了。
有兴趣的同学可以扫码体验下小程序。
小程序名称 :智能配音实用工具;
小程序二维码 :