微信小程序音频功能开发实(cai)践(keng)

1. 需求分析与开发方案

1.1 需求简介

最近产品给我们提出了“在小程序中播放音频课程”的需求,主要是有四个要点:
  • 课程管理:进入某个课程的播放页面,获取全部音频列表,但暂时不播放。
  • 音频管理:支持在播放页面,点击任意音频进行播放;可自动播放下一首。比如这样

  • 进度控件:支持拖动修改进度/上下首/暂停/播放,就像下面这样。

  • 全局播放:当用户暂时离开小程序时,在微信聊天列表页顶部展示背景音频。
就像这样子。

1.2 开发分析

好了,问题来了,怎么实现上面这几个需求呢?
我陷入了沉思…………
第一条“课程管理”不难,全局维护一个数组就好了。
第二条“音频管理”看上去是个麻烦,一开始我想到了小程序提供的 audio控件
但是随即我就否决掉了这种想法,理由主要有两点:
  • 微信官方提供的audio控件有默认的样式,如下图,这与设计稿的需求不相符。
  • 经过在微信官方提供的小程序实例Demo中亲测,如果使用audio控件,那么当我退出当前页面的时候,音频会消失,这没有办法满足PM要求的“全局播放”
因此,我决定采用微信提供的 backgroundAudioManager

1.2.1 backgroundAudioManager简介

按官方文档的说法,backgroundAudioManager是:
全局唯一的背景音频管理器
下面列出它的部分重要属性和重要的方法:
属性:
  • duration:当前音频长度,可以用来初始化播放控件的值。
  • currentTime:当前播放的位置,可以用来更新播放控件的进度值
  • paused:false为播放,true表示停止/暂停
  • src:音频数据源,注意设置src的时候会自动播放
  • title:音频标题(刚刚在微信聊天列表页顶部展示的音频title“为什么秋冬季节孩子易生病”,就是通过这里设置的)
方法:
  • play/pause/stop/seek:可以进行音频常见的播放控制,其中seek是跳转到特定播放进度的方法
  • onPlay/onPause/onStop/onEnded:响应特定事件,其中onStop是主动停止,onEnded是自动播放完毕(这可用于实现“连续播放”)
  • onTimeUpdate:背景音频播放进度更新事件,可与前面的currentTime属性结合在一起,去更新控件的值。
  • onWaiting/onCanplay:音频通常不会立刻就能播放,这两个方法可以在音频加载的时候为用户做一些提示。
更多的消息请查看它的 官方文档

1.2.2 播放控件

第三条“播放控件”也不算太难,播放/暂停/上下首都用小图片就可以了。
但是难点在于播放进度条的模拟,前面已经说到audio控件的样式是不符合需求的。
那么我决定采用slider来模拟,应该也可以搞定。
第四条,前面已经说了,用backgroundAudioManager实现“全局播放”。

1.2.3 开发方案确定

好了,需求分析得差不多了,我们要开发这个需求,需要三个对象,
  • 课程管理对象,负责维护课程信息和课程音频列表,不负责播放

  • 音频管理对象,即backgroundAudioManager,负责管理音频的播放,其中只有changeAudio方法具有修改音频的权限

  • 播放控件。

有了这几个对象,课程管理/音频管理/进度控件/全局播放就都可以搞定啦。
不过,话虽然这么说,但是实际实现需求总是会碰到各种各样的问题。

2. 功能实现

因为需求实在太多了,我没法一一列出,在这里就介绍一些需要技巧的需求

2.1 Slider控件模拟进度

前面提到,控件大概长这样

所以得用slider来模拟,但是模拟并不容易。
哈?你说为什么?我慢慢告诉你。

2.1.1 需求一:控件随着音频播放,自动更新

PM的需求是:控件随着音频播放,自动更新进度,左值随着进度更新,右值为音频总长度。
但是小程序自带的slider不支持展示左右值,我们只能自己模拟。
<!--  音频进度控件 -->
<view class="course-control-process">
    // 左值展示,currentProcess
    <text class="current-process">{{currentProcess}}</text>
    
    // 进度条
    <slider
      bindchange="hanleSliderChange"               // 响应拖动事件
      bindtouchstart="handleSliderMoveStart"
      bindtouchend="handleSliderMoveEnd"
      min="0"
      max="{{sliderMax}}"
      activeColor="#8f7df0"
      value="{{sliderValue}}"/>
      
    // 右值展示,totalProcess
    <text class="total-process">{{totalProcess}}</text>
</view> 复制代码
currentProcess为左值、totalProcess为右值、sliderMax控件最大值、sliderValue为当前控件的value。
那么,怎么更新这些数值呢?前面提到backgroundAudioManager有一个onTimeUpdate方法,在这里面去更新进度值就可以了。
//  formatAudioProcess函数我就不放了,就是把时间格式化成00:15这样就行了

onTimeUpdate() {
  // 省略一些判断代码
  self.page.setData({
    currentProcess: formatAudioProcess(globalBgAudioManager.currentTime),
    sliderValue: Math.floor(globalBgAudioManager.currentTime)
  });
}, 复制代码
这里有一件值得注意的是,就是在进入同一个课程的播放页时,由于原page很可能已经销毁(比如你执行navigateTo),因此需要在初始化的时候更新原有的data值,比如当前的播放进度currentProcess,这就要从当前的backgroundAudioManager里去拿。
## 检查是否同一个课程,如果是的话,更新进度

if (id !== globalCourseAudioListManager.getCurrentCourseInfo().id)

## 更新方法
  updateControlsInOldAudio() {
  
    // 获取当前音频
    const currentAudio = globalCourseAudioListManager.getCurrentAudio();
    
    // 更新进度和控件内容
    this.setData({
      currentProcess: formatAudioProcess(globalBgAudioManager.currentTime),
      sliderValue: formatAudioProcess(globalBgAudioManager.currentTime),
      sliderMax: Math.floor(currentAudio.duration / 1000) - 1 || 0,
      totalProcess: formatAudioProcess(currentAudio.duration / 1000 || 0),
      hasNextAudio: !globalCourseAudioListManager.isRightEdge() && this.data.hasBuy,
      hasPrevAudio: !globalCourseAudioListManager.isLeftEdge() && this.data.hasBuy,
      paused: globalBgAudioManager.paused,
      currentPlayingAudioId: currentAudio.audio_id,
      courseChapterTitle: currentAudio.title
    });
  }, 复制代码

2.1.2 需求二:拖动进度条,自动跳转到特定位置

注意到前面slider控件具有bindchange="hanleSliderChange",那么我们就可以拿到value值,然后去更新音频了
hanleSliderChange(e) {
    const position = e.detail.value;
    this.seekCurrentAudio(position);
  },
  // 拖动进度条控件
  seekCurrentAudio(position) {
  
    // 更新进度条
    const page = this;
    // 音频控制跳转
    // 这里有一个诡异bug:seek在暂停状态下无法改变currentTime,需要先play后pause
    const pauseStatusWhenSlide = globalBgAudioManager.paused;
    if (pauseStatusWhenSlide) {
      globalBgAudioManager.play();
    }
    
    globalBgAudioManager.seek({
      position: Math.floor(position),
      success: () => {
        page.setData({
          currentProcess: formatAudioProcess(position),
          sliderValue: Math.floor(position)
        });
        if (pauseStatusWhenSlide) {
          globalBgAudioManager.pause();
        }
        console.log(`The process of the audio is now in ${globalBgAudioManager.currentTime}s`);
      }
    });
  }, 复制代码
看上去有一点比较奇怪是不是?backgroundAudioManager的seek方法是没有success回调的,这里被我改了。
seek(options) {
    wx.seekBackgroundAudio(options);  // 这样实现,就可以配置success回调了
} 复制代码
但是,“onTimeUpdate事件触发slider控件更新”和“手动拖动触发slider更新”是有冲突的,假如说两个函数都要改slider,听谁的?
但是,可以利用监测touchstart和touchend事件,来检查是否在滑动。如果在滑动,禁止onTimeUpdate去修改slider控件更新就行了。
因此,我先设定一个变量,来标记是否正在滑动
handleSliderMoveStart() {
    this.setData({
      isMovingSlider: true
    });
  },
  handleSliderMoveEnd() {
    this.setData({
      isMovingSlider: false
    });
  }, 复制代码
在滑动期间禁止更新进度条即可
onTimeUpdate() {
    // 在move的时候,不要更新进度条控件
    if (!self.page.data.isMovingSlider) {
      self.page.setData({
        currentProcess: formatAudioProcess(globalBgAudioManager.currentTime),
        sliderValue: Math.floor(globalBgAudioManager.currentTime)
      });
    }
    
    // 其他省略
}, 复制代码

2.2 backgroundAudioManager相关需求

在开始下一个需求介绍之前,不知道各位有没有疑问:
我在哪儿设置的onTimeupdate方法?
OK,我来介绍下。
首先,全局获取
this.backgroundAudioManager = wx.getBackgroundAudioManager(); 复制代码
其次,在play/index.js中引入backgroundAudioManager
let globalBgAudioManager = app.backgroundAudioManager; 复制代码
在适当的时候,比如我就是onLoad,扩展globalBgAudioManager对象。——这样我就把具体的功能放进了具体的page中,不同的page中针对backgroundAudioManager可以有不同的实现。
this.initBgAudioListManager(); 复制代码
接下来我们看看这个拓展到底干了什么。
initBgAudioListManager() {
    // options中的函数在执行的时候,this指向函数本身(亲测),因此这里需要保存Page对应的this。
    const page = this;
    const self = globalBgAudioManager;
    const options = {
        // options在后面会介绍
    };
    
    // decorateBgAudioListManager函数,直接修改globalBgAudioManager对象,从而实现方法的拓展
    globalBgAudioManager = decorateBgAudioListManager(globalBgAudioManager, options); 复制代码
好了,怎么引入的现在已经说完了,接下来就讲需求,也就是介绍options里面干了什么。
其实options里面都是backgroundAudioManager已经有的方法,具体可以参考 文档。我只是做了改写

2.2.1 需求三:绕过onCanPlay,提醒用户音频在加载

众所周知,音频需要加载一段时间才可以播放,为此小程序的全局播放对象,即backgroundAudioManager提供了onWaiting和onCanplay,看上去天生就是为了音频加载的交互实现的。
但不知道为什么,onCanplay无!法!触!发!和社区提了这个问题也没有人鸟我哎……心痛。
算了算了,他强由他强,我绕我的墙。。。
首先,在options中,改写onWaiting:先提示用户正在加载当中,isWaiting进行标记(“看!音频在Waiting!”)
const options = {
    onWaiting() {
        wx.showLoading({
          title: '音频加载中…'
        });
        globalBgAudioManager.isWaiting = true;
    },
} 复制代码

然后接下来,在时间进度发生更新的时候(这相当于开始播放了),把Loading窗口关了就行。同样是在options中去改写onTimeUpdate。
onTimeUpdate() {
    if (self.isWaiting) {
      self.isWaiting = false;
      setTimeout(() => {
        wx.hideLoading();
      }, 300);
      // 设置300ms是为了避免某些音频加载过快而导致Loading效果一闪而过对用户造成糟糕的体验
    }
    // 以下代码省略
}, 复制代码

2.2.2 需求四:点击某个音频,实现播放

这个需求的麻烦之处,在于需要检查点击的音频是什么,比如假定你在播放音频A,你重新点击A,那当然不用重播了啊。
以及iOS版本的小程序和阿里云服务器似乎有点过节,下面就会看到。
在pages/play/index内部,先响应点击事件
## pages/play/index

  outlineOperation(e) {
    // 获取音频地址
    const courseAudio = e.currentTarget.dataset.outline || {};
    const targetAudioId = courseAudio.audio_id;
    // 中间省略一系列合法性检查。
    this.playTargetAudio(targetAudioId);
  }, 复制代码
然后执行播放相关操作,这个globalCourseAudioListManager虽然前面提到过,但是一会儿再具体介绍,它做了什么就直接看注释好了
## pages/play/index

  /**
   * 点击/自动播放 目标音频
   * @param {*Number} targetAudioId
   * - 检查是否点击到同一个音频
   * - 检查是否完全播放完毕
   * - 若未播放完毕,或者点击的不是同一个音频,先暂停当前音频
   * - 执行音频播放操作
   */
  playTargetAudio(targetAudioId) {
    const currentAudio = globalCourseAudioListManager.getCurrentAudio();
    
    // 点击未停止的原音频的话,没必要响应
    if (targetAudioId === currentAudio.audio_id && !!globalBgAudioManager.currentTime) {
      return false;
    } else {
      this.getAudioSrc(targetAudioId).then(() => {
      
        // 若未暂停,则先暂停
        if (!globalBgAudioManager.paused) {
          globalBgAudioManager.pause();
        }
        
        // 全局切换当前播放的音频index(此时还没有开始播放)
        globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);

        // 更新当前控件状态,比如新音频的title和长度,总要更新吧。
        this.updateControlsInNewAudio();
        
        // 更换并且播放背景音乐
        globalBgAudioManager.changeAudio();
      });
    }
  }, 复制代码
好了,终于到这个changeAudio函数了,它也是刚刚提到的options里面的一部分。
## changeAudio是options的属性,被扩展进入了backgroundAudioManager

  // 修改当前音频
  changeAudio() {
  
    // 获取并且
    const { url, audio_id, title, content_type_signare_url } = globalCourseAudioListManager.getCurrentAudio();
    const { doctor, name, image } = globalCourseAudioListManager.courseInfo;
    self.title = title;
    self.epname = name;
    self.audioId = audio_id;
    self.coverImgUrl = image;
    self.singer = doctor.nickname || '丁香医生';
    
    // iOS使用content_type_signare_url
    const src = isIOS() ? content_type_signare_url : url;
    if (!src) {
      showToast({
        title: '音频丢失,无法播放',
        icon: 'warn',
        duration: 2000
      });
    } else {
      self.src = src;
    }
} 复制代码
为什么这里iOS要用content_type_signare_url?(它是我们后端返回的一个字段)
因为iOS小程序发起音频文件请求的时候,会默认带上content-type:octet-stream,而我们的音频文件URL又带有Signatrue签名参数,阿里云服务器似乎会默认把content-type加入到签名当中……于是我就遇上了403错误。
解决方案有两个:
  • 让后端负责CDN服务器的同事,在我请求获取音频src地址之前,先请求一次资源,并且做好缓存。
  • 把音频地址改成公开的。

2.3 courseAudioListManager相关需求

前面提到,我需要维护一个全局的课程信息和音频列表的管理对象,然后,就能操作音频列表了。
## 在app.js当中初始化
this.courseAudioListManager = createCourseAudioListManager();

## 在pages/play/index.js里面引用
const globalCourseAudioListManager = app.courseAudioListManager; 复制代码
这个对象其实没有太多好介绍的,比较简单。
又比如,前面提到“点击某个音频并自动播放”,其中有一步是这样的。
// 全局切换当前播放的音频index(此时还没有开始播放)
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId); 复制代码
就是根据id来修改音频的索引,它是这么干的。
changeCurrentAudioById(audioId = -1) {
    this.currentIndex = this.audioList.findIndex(audio => audio.audio_id === audioId);
}, 复制代码
其他,具体有哪些方法,可以看前面的1.2.3节“开发方案确定”中的脑图。
不过,它有个addAudioSrc,可以解决重播失败的问题。

2.3.1 用重新加载src的方法,解决重播失败

当一个音频的播放被“停止”而不是“暂停”的时候,再调用play()方法,是不会重播的,亲测调用seek方法执行跳转也不行。
比如,当我试听完了一段音频,想重新听的时候,常规的play是无能的……怎么办?当然是绕过去啊
当你点击播放按钮的时候,
  • 首先通过一系列检查,就会触发下面这个playTargetAudio
handleStartPlayClick() {

    // 以上省略,若globalBgAudioManager.currentTime为false,表示认为你在点击一个已经播放完毕的音频
    } else if (!globalBgAudioManager.currentTime) {
      this.playTargetAudio(currentAudio.audio_id);
    } else 
    // 以下省略
} 复制代码
  • 在playTargetAudio内部依次执行getAudioSrc/changeCurrentAudioById/changeAudio
this.getAudioSrc(targetAudioId).then(() => {
        // 省略
        
        // 全局切换当前播放的音频index
        globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);
        
        // 省略
        
        // 更换并且播放背景音乐
        globalBgAudioManager.changeAudio();
      });
    } 复制代码
  • 在getAudioSrc内部,主要的作用就是,更新了一下新的src
globalCourseAudioListManager.addAudioSrc(res.items[0]); 复制代码
然后我们看看addAudioSrc干了什么
## 现在在courseAudioListManager内部

    addAudioSrc(audioSrcObject) {
      this.audioList = this.audioList.map(audio => {
      
        // 强制更新特定id的audio对象
        // 新的src隐藏在audioSrcObject里面
        
        if (Number(audio.audio_id) === Number(audioSrcObject.id)) {
          return Object.assign(audio, audioSrcObject, { id: audio.id });
        } else {
          return audio;
        }
      });
    }, 复制代码
现在src已经更新完了。看上去每次获取到的音频src都指向同一个音频,但是,音频的src地址是带有时间戳的,这避免了缓存,backgroundAudioManager设置src的时候,就会重新加载了~
当然这样,就没有缓存了,交互上会有所牺牲,每次重播的时候都会闪一下“音频加载中”。
如果各位有好的办法实现缓存,欢迎交流哈。

3. 其他一些经验

  • 如果代码过长,不要用三目运算符,很难读。
  • 音频播放可能出现错误,需要用onError加以捕获。
  • 最后,欢迎留言~!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
拼音数据(无声调):a ai an ang ao ba bai ban bang bao bei ben beng bi bian biao bie bin bing bo bu ca cai can cang cao ce cen ceng cha chai chan chang chao che chen cheng chi chong chou chu chua chuai chuan chuang chui chun chuo ci cong cou cu cuan cui cun cuo da dai dan dang dao de den dei deng di dia dian diao die ding diu dong dou du duan dui dun duo e ei en eng er fa fan fang fei fen feng fo fou fu ga gai gan gang gao ge gei gen geng gong gou gu gua guai guan guang gui gun guo ha hai han hang hao he hei hen heng hong hou hu hua huai huan huang hui hun huo ji jia jian jiang jiao jie jin jing jiong jiu ju juan jue jun ka kai kan kang kao ke ken keng kong kou ku kua kuai kuan kuang kui kun kuo la lai lan lang lao le lei leng li lia lian liang liao lie lin ling liu long lou lu lü luan lue lüe lun luo ma mai man mang mao me mei men meng mi mian miao mie min ming miu mo mou mu na nai nan nang nao ne nei nen neng ng ni nian niang niao nie nin ning niu nong nou nu nü nuan nüe nuo nun ou pa pai pan pang pao pei pen peng pi pian piao pie pin ping po pou pu qi qia qian qiang qiao qie qin qing qiong qiu qu quan que qun ran rang rao re ren reng ri rong rou ru ruan rui run ruo sa sai san sang sao se sen seng sha shai shan shang shao she shei shen sheng shi shou shu shua shuai shuan shuang shui shun shuo si song sou su suan sui sun suo ta tai tan tang tao te teng ti tian tiao tie ting tong tou tu tuan tui tun tuo 定义数据集:采用字符模型,因此一个字符为一个样本。每个样本采用one-hot编码。 样本是时间相关的,分别现序列的随机采样和序列的顺序划分 标签Y与X同形状,但时间超前1 准备数据:一次梯度更新使用的数据形状为:(时间步,Batch,类别数) 现基本循环神经网络模型 循环单元为nn.RNN或GRU 输出层的全连接使用RNN所有时间步的输出 隐状态初始值为0 测试前向传播 如果采用顺序划分,需梯度截断 训练:损失函数为平均交叉熵 预测:给定一个前缀,进行单步预测和K步预测
05-26

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值