一、问题现象
微信小程序已经放弃了基于wx.createAudioContext()的audio组件,要求开发人员使用功能更强大的wx.createInnerAudioContext()组件,新的给件功能的确要强大得多,不需要在页面上布置组件,旧的组件只有play、pause、seek三个事件,功能是比较弱,新的Inner组件增加了很多事件,并且还有监控事件,可以自己定义UI界面,通过监控事件可以在准备好播放、播放完成等,增加用户函数,可以统计用户使用情况,可以进行很多操作,如插播内容、转换等。
但是,将组件按为新的wx.createInnerAudioContext()后遇到了一个大坑,即部分安卓手机播放时后端报错,无法播放语音文件。在电脑上调试、浏览器上访问、苹果手机上都可以,就是安装手机网上提这个问题的人很多,但都没有提供完整的解决方案,微信官方也没有提供解决方案,只是让大家提交代码片断,只说是与机型不兼容。
真机调试时,后台错误代码:
errCode: 10001, errMsg: "errCode:62, err:load or init native decode so fail"。
同时音频对象监听错误的事件,也能监听到错误,错误信息:
监听音频播放错误事件 {errCode: 0, errMsg: ""}
为了项目进展,没有办法,先退回去用旧的wx.createAudioContext()组件,这个组件虽然功能差一些,但基本上能用,先凑合作。后来,有时间后进一步在研究了手机兼容问题、音频文件服务Header、保存文件OSS问题、音频文件内部格式问题后,终于找到了解决方案,特此完整记录一下,希望对遇到类似问题的同行有用。
二、原因分析
刚碰到这个问题也是一头雾水,百度上能找到的相关信息很少,微信小程序社区提这个问题的人很多,将这个错误代码在社区上检索,有7000多条信息,但都是反馈问题的,没有找到可用的解决方案,不过线索找到了几条。
2.1 检测组件使用方法
刚开始怀疑程序编写方法不对,而微信小程序的官方手册介绍内容很少,还不容易理解,官方手册将对象申明放到头上,并且按const申明,并且只有一个SRC,我的项目中要用到多个动态SRC,并且SRC是用户选择不同景点,播放不同的语音介绍。
const innerAudioContext = wx.createInnerAudioContext();
刚开始,是怀疑此问题,后专门写了一个页面,按照官方的写法,结果一样,仍然不行。后来反复尝试写各种方案,将对象放到this,this.data仍然不行,测试过将对象所有按钮事件,监控事件都写了来跟踪各种情况 ,结果仍是一样,在测试平台、ios真机测试行,安卓真机测试不行。
2.2 文件请求header问题排除
先退回旧组件继续使用,旧的组件需要在页面文件添加组件,为了在实现多个对象用一个后台组件自己定义了play和pause图标,定认了一个audio组,并隐藏起来,实现与inner对象差不多的效果,但控制起来比较麻烦,且没有监控事件,后续做播放统计会有一些问题。
不甘心退回用旧组件,一有时间继续研究该问题,在微信社区发现讨论此问题的很多,但没有明确答案,基本上说是不兼容,问题原因是微信小程序的组件不兼容,只能等微信升级,但是这个问题从2019年3月就广泛出现了啊,一年多了微信还没升级吗。后来找到一个兄弟说解决了是request heder问题,修改请求的heaer即可,但没有给出方法啊,也没有进一步回复。
反复研究request header,在我用的VUE的request确实是判断header,后台header需要调协为octect-stream,但是我后台已经是mp3文件了啊,不论用新旧组件,都只有一个src参数,指向后端地址,没有地方加header啊,除非后台将文件读取到内存,然后再以stream反馈给前端,但是觉得使用OSS就是让前端可以直接通过链接方式,以流方式使用多媒体文件啊,如果再读回服务器内存,再反馈给前端不对啊,应该没有必要。
if (headers['content-type'] === 'application/octet-stream;charset=UTF-8') {
return res.data
}
继续研究,如果使用OSS或者其它服务器用链接访问,都无法增加header的请求头,解决这个方案的兄弟应该是以流方式提供给前端小程序的,不是以后端OSS对象文件服务提供的小程序的。
2.3 文件服务器问题分析
继续下载微信社区上相关的代码片段,发现一个有意思的问题,有iac应用一个代码片段,第一次播放报错,等待一会儿,再点击播放按钮时,就不会报错了。觉得这个问题是否应该是服务器与小程序的冲突所至,文件前端还没有获取完成,前端就播放了,将自动播放去掉,修改为自播放,或者在onCanPlay事件中再播放,还有错,问题没有得到解决。想到自己用的OSS服务器是阿里,小程序是腾讯,是不是这两个公司互相限制造成的问题。沿着这个思路,检查文件服务位置进行测试,果然将其它服务器基本上行,自己在阿里OSS上的不行,以为是阿里和腾讯互相限制的结果,但想想不会啊,计算机上和苹果计算机上行的啊。于是可以播放的文件下载下来,上传到自己的OSS上进行测试,竟然能够播放,于是想明白了,是不是文件本身有问题啊。至此,排除了小程序写法、请求头参数、文件服务器问题,此问题应该是文件本身问题,于此顺着此思路,还真找到了相关资料,解决了此问题。
三、MP3文件标签分析
由于我是自己学习,前后端都比较了解,顺着MP3文件格式问题思路继续,查找,果然在微信小程序开发社区找到了下面文章,该文章介绍引用了MP3文件格式标记问题,并且提到了讯飞语音合成的mp3不能播放,转百度语音合成就以。并在这个问题的回复文间中,介绍了一个给mp3文件添加标记的程序,这两个方案结合在一起给了解决问题的思路与方法。
https://developers.weixin.qq.com/community/develop/article/doc/000460e9bd4c982e1609f4f725b013
3.1 MP3文件格式解析
MP3 文件大体分为三部分:TAG_V2(ID3V2),音频数据,TAG_V1(ID3V1),详细的见下面文章,不再赘述。
https://blog.csdn.net/datamining2005/article/details/78954367
3.2 讯飞语音合成MP3文件格式
根据3.1介绍的内容,下载格式工厂,对讯飞语音合成文件信息进行解析,果然只有文件size,其它信息都没有,如下图。
另外找了下MP3音乐文件解析如下,有详细的信息。
用十六进制文件找开,头上没有id3,后面没有tag
至此,可以判断是讯飞合成的语音文件是使用lame3.1格式对音频进行编码,然后以扩展名MP3进行识别,在游览器、微信小程序调试器、苹果真机调试这两个信息足够解析并播放,但安卓版微信小程序是标签进行识别的,无法正常播放。
对此,对讯飞合成用格式工厂进行转换,然后上传到阿里OSS文件上,测试能够正常播放。至此,问题的原因定位清楚:
1、讯飞语音合成的MP3文件只是用lame3.1格式进行编码,并以mp3扩展名进行标识识别,并没用在文件中添加MP3音频文件前后增加__TAG_V2(ID3V2)、TAG_V1(ID3V1)__标记。
2、微信小程序安卓上的音频播放器有BUG,是按音频文件内部的TAG来识别和解析文件,对于TAG不正确的,不能播放,但这个问题在计算机浏览器和苹果计算机上存在,只存在安卓系统,因此前端工程师很难解决这个问题,导致微信社区上千个这样的问题没有完整的解决方案回复。特别是,很多偶然现现的问题,原因是文件的问题,不是不兼容问题。
四、修复方案
找到根本原因后,参照下面方案进行修复,分析讯飞语音合成文件的资料和参数,没有找到添加TAG的方法,没办法自己添加吧,感谢两位同行兄弟提供的思路。
https://www.cnblogs.com/ztysir/p/5513853.html
4.1 语音文件添加TAG方法
添加两个方法,分别根据传入的音频文件名称,作者,专辑名,添加到头和尾部,这两个方法基本上借用上面兄弟的代码。
/**
* * 合成mp3文件的tag
* *
* * [@songName](/user/songName) 名称
* * [@artistName](/user/artistName) 作者
* * [@albumName](/user/albumName) 专辑
* * [@return](/user/return) 128字节的D3V1字符串
*
*
* [@return](/user/return)
*/
private static byte[] composeD3V1(String songName, String artistName,
String albumName) {
try {
byte[] tagByteArray = new byte[128];
byte[] songNameByteArray = songName.getBytes("GBK");
byte[] artistNameByteArray = artistName.getBytes("GBK");
byte[] albumNameByteArray = albumName.getBytes("GBK");
int songNameByteArrayLength = songNameByteArray.length;
int artistNameByteArrayLength = artistNameByteArray.length;
int albumNameByteArrayLength = albumNameByteArray.length;
songNameByteArrayLength = songNameByteArrayLength > 30 ? 30 : songNameByteArrayLength;
artistNameByteArrayLength =
artistNameByteArrayLength > 30 ? 30 : artistNameByteArrayLength;
albumNameByteArrayLength =
albumNameByteArrayLength > 30 ? 30 : albumNameByteArrayLength;
System.arraycopy("TAG".getBytes(), 0, tagByteArray, 0, 3);
System.arraycopy(songNameByteArray, 0, tagByteArray, 3, songNameByteArrayLength);
System.arraycopy(artistNameByteArray, 0, tagByteArray, 33, artistNameByteArrayLength);
System.arraycopy(albumNameByteArray, 0, tagByteArray, 63, albumNameByteArrayLength);
// 将流派显示为指定音乐的流派
tagByteArray[127] = (byte) 0xFF;
return tagByteArray;
} catch (Exception e) {
log.error("添加MP3文件TAG标签异常", e);
}
return new byte[0];
}
/**
* * 合成mp3文件的id
* *
* * [@songName](/user/songName) 名称
* * [@artistName](/user/artistName) 作者
* * [@albumName](/user/albumName) 专辑
* * [@return](/user/return) 128字节的D3V2字符串
*
*
* [@return](/user/return)
*/
public static byte[] composeD3V2(String songName, String artistName,
String albumName) {
try {
byte[] encodeByte = {3}; // 03 表示的UTF8编码
byte[] tagByteArray;
byte[] tagHeadByteArray;
byte[] tagFrameHeadByteArray;
byte[] songNameByteArray = songName.getBytes("UTF-8");
byte[] artistNameByteArray = artistName.getBytes("UTF-8");
byte[] albumNameByteArray = albumName.getBytes("UTF-8");
final int tagHeadLength = 10;
final int tagFrameHeadLength = 10;
final int tagFrameEncodeLength = 1;
final int tagFillByteLength = 20; // 这个填充字节是我看到其他MP3文件ID3标签都会在尾端添加的数据,为了保险起见我也加上了
int byteArrayOffset = 0;
int songNameByteArrayLength = songNameByteArray.length;
int artistNameByteArrayLength = artistNameByteArray.length;
int albumNameByteArrayLength = albumNameByteArray.length;
int songNameFrameTotalLength = songNameByteArrayLength + tagFrameEncodeLength;
int artistNameFrameTotalLength = artistNameByteArrayLength + tagFrameEncodeLength;
int albumNameFrameTotalLength = albumNameByteArrayLength + tagFrameEncodeLength;
int tagTotalLength = tagHeadLength + tagFrameHeadLength + songNameByteArrayLength +
tagFrameHeadLength + artistNameByteArrayLength +
tagFrameHeadLength + albumNameByteArrayLength +
tagFillByteLength;
int tagContentLength = tagTotalLength - tagHeadLength;
tagByteArray = new byte[tagTotalLength];
tagHeadByteArray = new byte[tagHeadLength];
System.arraycopy("ID3".getBytes(), 0, tagHeadByteArray, 0, 3);
tagHeadByteArray[3] = 3;
tagHeadByteArray[4] = 0;
tagHeadByteArray[5] = 0;
tagHeadByteArray[6] = (byte) ((tagContentLength >> 7 >> 7 >> 7) % 128);
tagHeadByteArray[7] = (byte) ((tagContentLength >> 7 >> 7) % 128);
tagHeadByteArray[8] = (byte) ((tagContentLength >> 7) % 128);
tagHeadByteArray[9] = (byte) (tagContentLength % 128);
System.arraycopy(tagHeadByteArray, 0, tagByteArray, byteArrayOffset,
tagHeadLength);
byteArrayOffset += tagHeadLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TIT2".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((songNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((songNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((songNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (songNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(songNameByteArray, 0, tagByteArray, byteArrayOffset,
songNameByteArrayLength);
byteArrayOffset += songNameByteArrayLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TPE1".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((artistNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((artistNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((artistNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (artistNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(artistNameByteArray, 0, tagByteArray, byteArrayOffset,
artistNameByteArrayLength);
byteArrayOffset += artistNameByteArrayLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TALB".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((albumNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((albumNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((albumNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (albumNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(albumNameByteArray, 0, tagByteArray, byteArrayOffset,
albumNameByteArrayLength);
return tagByteArray;
} catch (Exception e) {
log.error("添加MP3文件TAG标签异常", e);
}
return new byte[0];
}
4.2 语音合成文件中添加
语音合成文件是分多个byte[]数据,4.1的两上方未能返回的也是byte[]数据,利用ByteUtils.concat,可以将之个byte[]合并起来,再反馈给前端并保存到OSS中,通过这个方法自动处理的文件就具有TAG头和尾,经过测试前端可以正常访问。
byte[] mp3Tag = XunFeiUtil.composeD3V2("景点介绍", "智慧趣游", "趣游信息");
InputStream is = new ByteArrayInputStream(ByteUtils.concat(mp3Tag, result));
ossFileUtils.uploadFile2OSS(is, fileName);
4.3、前端实现方法
const app = getApp()
Page({
data: {
iac: null,
value: 0, //正在播放时长
duration: 0, //总时长
isplay: false,
isloading: false,
isdrag: false,
src: "https://xiyoutianxia.oss-cn-hangzhou.aliyuncs.com/upload/000.mp3"
},
onLoad: function(options) {
let that = this;
that.initInnerAudioContext();
},
//播放
play: function() {
if (this.data.isplay) {
this.data.iac.pause();
} else {
this.data.iac.play();
}
},
sliderChanging: function(e) {
let that = this;
if (!that.data.isdrag) {
let drplay = that.data.isplay;
that.setData({
isdrag: true,
drplay: drplay
});
if (drplay) {
that.data.iac.pause();
}
}
},
// 进度条拖拽
sliderChange: function(e) {
console.log('sliderChange');
let that = this;
let value = parseInt(e.detail.value);
let iac = that.data.iac;
that.setData({
isdrag: false
});
iac.seek(value);
if (that.data.drplay) {
iac.play();
}
},
// 页面卸载时停止播放
onUnload() {
//停止播放
this.data.iac.stop();
this.data.iac.destroy();
},
initInnerAudioContext() {
let that = this;
// 创建音频上下文
let iac = wx.createInnerAudioContext();
iac = wx.createInnerAudioContext();
iac.src = that.data.src;
// 监听音频播放事件
iac.onPlay(() => {
console.log('onPlay');
that.setData({
isplay: true
});
});
// 监听音频播放进度更新事件
iac.onTimeUpdate(function() {
if (that.data.isdrag) {
return;
}
let duration = iac.duration;
let value = iac.currentTime;
that.setData({
duration: duration,
value: value
});
});
iac.onWaiting(function() {
console.log('onWaiting');
that.setData({
isloading: true
});
});
iac.onCanplay(function() {
console.log('onCanplay')
that.setData({
isloading: false
});
});
// 监听音频暂停事件
iac.onPause(function() {
console.log('onPause');
that.setData({
isplay: false
});
});
// 监听音频停止事件
iac.onStop(function() {
console.log('onStop');
that.setData({
isplay: false
});
iac.seek(0);
});
// 监听音频自然播放至结束的事件
iac.onEnded(function() {
console.log('onEnded');
that.setData({
isplay: false
});
iac.seek(0);
});
// 监听音频播放错误事件
iac.onError(err => {
console.log('监听音频播放错误事件', err, iac.src);
that.setData({
isplay: false,
isloading: false
});
wx.showToast({
icon: 'none',
title: '音频播放出错!',
});
});
that.setData({
iac: iac
});
},
//格式化秒 00:00
format_sec(sec, is_milli) {
if (!sec) {
return '00:00';
}
sec = parseFloat(sec + '');
if (is_milli) {
sec = sec / 1000;
}
let min = Math.floor(sec / 60);
min = (min < 10 ? '0' : '') + min;
sec = Math.round(sec % 60);
sec = (sec < 10 ? '0' : '') + sec;
return min + ':' + sec;
}
})
五、小结
这个问题隐藏得比较深,初一看是前端兼容性问题,深入分析后是小程序前端BUG再加上数据的问题导致的,数据问题不是总会出现,导致这个问题比较难复现和最终解决。