自打编程以来,经常性的一个活动就是做音频播放器,以前用 C 做的时候很痛苦,需要管理很多内存和数据结构,也没有图形,后来稍微学了点前端之后就开始做网页版的,收获很多,也做了梦寐以求的时域和频域的音频波谱图,之后学 Java 的时候也做了一个当作课程设计交了上去,最近我琢磨着把播放器搞到小程序上面去。
# 方案分析 ↵
小程序在音频上的处理并不是很强大,没有提供 Audio 对象做各种音频二进制数据的处理,只提供了一些简单操作,比如暂停,快进之类。
此外,小程序有两种播放音乐的方式,一种是利用
<audio>
标签,一种是利用微信背景音乐这类接口。一开始我使用了 audio 标签来做,后来我发现,audio 那个并没有提供获取音频时长的 API 可以用,而背景音乐的
wx.getBackgroundAudioPlayerState
可以获取音频的一些元数据。而且,也没有办法在前端获取到音频的专辑封面,因此这方面的工作只能交由后台来做。
MP3 的音频元数据标准规范是
ID1/2/3
, 现在通常我们看到的 MP3 文件都是 ID3 或者 ID3V2 的,我在 NPM 上寻找了一番,最后选择了 npm - jsmediatags 用于解析 ID3 元数据, 原因如下:- 支持解析 URL 形式的 MP3 文件 主要是这点
- 它是
JavaScript-ID3-Reader
的下一代,我之前做的播放器用的就是这个,完全继承了语法,因此看起来不会蛋疼
可能有人会问我为什么不在小程序上用
JavaScript-ID3-Reader
?原因很简单,它是基于 XHR 的,小程序上屏蔽了 XHR 因此无法使用。最后我拟定的方案是:
- 前端用户输入音频 URL
- 服务器处理它,得到专辑封面
- 将封面传到七牛静态存储上
- 最后把数据保存在 Mongo 里
- 而且做好缓存,对于同一个 URL 的音频,将首先从数据库中调用,而不是重新处理一遍上面的流程
# 路由设计 ↵
拟定路由两个:
- GET /api/mp3
- POST /api/mp3
第一个用于获取服务器上保存的音乐,第二个则要求用户输入 src 然后服务器处理后续。
前端需要两个页面:
- /pages/musicList/musicList
- /pages/music/music?_id=$(MUSIC_ID)
一个是列表,一个是音乐播放
# 数据库设计 ↵
直接上 Mongoose Schemas
00schemas.mp3 = mongoose.Schema({ 01 url: { 02 type: String, 03 required: true 04 }, 05 picture: { 06 type: String, 07 required: true 08 }, 09 info: { 10 type: Object 11 }, 12 who: { 13 type: mongoose.Schema.Types.ObjectId, 14 ref: 'user', 15 required: true 16 }, 17 created_at: { 18 type: Date, 19 default: Date.now 20 } 21});
# reader.js ↵
reader.js 暴露函数
reader
,需要参数 src
, 读取 URL 并返回相关数据,这里面有很多异步流程,分别是:- 查询 src 是否在表 mp3 里面,如果有,则直接返回结果,否则走下一步
- 利用
jsmediatags
读取 ID3 文件头, 得到文件头对象id3
- 从
id3
里面抽出图片数据(以数组的形式),并转为Buffer
最后存储到磁盘上得到该图片路径路径picLocal
, 在此期间,利用uuid
生成该图片的名字picName
- 将图片上传到七牛得到图片远程链接
img
- 拼接以上过程中的数据得到形如下面的这个对象并返回:
00{ 01 url: src, 02 picture: img, 03 info: id3 04}
详细实现(已折叠):
代码较长 已折叠
00// id3/reader.js 01const jsmediatags = require("jsmediatags") 02 , fs = require('then-fs') 03 , path = require('path') 04 , url = require('url') 05 , { mp3Model } = require('../../tools/db') 06 , qnx = require('../../tools/qnx') 07 , uuid = require('uuid/v1') 08 , mkdir = require('../mkdir') 09 10// 创建临时文件夹 11mkdir(path.join(__dirname, 'pic')); 12 13function reader(src){ 14 let isCache = false; 15 16 return mp3Model.findOne({ 17 url: src 18 }).then(mp3 => { 19 if (mp3){ 20 isCache = true; 21 console.log('From Cache'); 22 return Promise.resolve(mp3); 23 } else { 24 isCache = false; 25 return readMp3From(src); 26 } 27 }) 28} 29 30function readMp3From(src){ 31 let picData, picName, picLocal, _id3; 32 33 return new Promise((res, rej) => { 34 // Try To Parsing ID3 File Meta Data 35 new jsmediatags.Reader(src) 36 // Target Fields 37 .setTagsToRead(["title", "artist", "album", "picture", "track", "genre", "TLE"]) 38 .read({ 39 onSuccess: function(tag) { 40 res(tag); 41 }, 42 onError: function(error) { 43 rej(error) 44 } 45 }); 46 }).then(id3 => { // Parse Success 47 // Basic Value 48 _id3 = id3; 49 picData = new Buffer(id3.tags.picture.data); 50 picName = uuid() + '.jpg'; 51 picLocal = path.join(__dirname, 'pic', picName); 52 53 // Write File To Local 54 return fs.writeFile( 55 picLocal, 56 picData 57 ); 58 }).then(suc => { // After Parsing, And Have Picture Saved To Local 59 // Try To Upload File To Qiniu 60 return qnx.upload(picLocal, 'album-pic/' + picName); 61 }).then(qiniuResponse => { 62 let { hash, key, img } = qiniuResponse; 63 64 _id3.tags.picture.data = []; 65 66 return { 67 url: src, 68 picture: img, 69 info: _id3 70 } 71 }).catch(err => { 72 console.log(err); 73 return Promise.reject({ 74 err: err, 75 msg: 'Error When Reading ID3' 76 }); 77 }) 78} 79 80module.exports = reader;
# 异步争用 ↵
我在前端封装的
http.client.js
设置了 HTTP 超时重传(5 秒重传),如果当用户发送音频 URL 供后台处理的一瞬间到完全处理完毕(即数据入库,生成缓存的时候)这段时间超过 5 秒,则会引发重传,而重传的时候由于数据并未生成缓存,因此会出现一次提交变出好多音频出来,这样显然不行。解决以上的问题有很多解决方案:
- 取消前端超时重传机制
- 数据入库前先检查看看有没有同名的再入库
- 每次发来的 URL 先扔进队列,由后台慢慢的一个接着一个慢慢处理
第一种方案绝不可行,在使用移动网络的时候很影响体验,因此否决。
如果时间比较赶可以采用第二种方式,唯一的缺憾就是会做多余的操作(走多几遍 reader.js)
第三种应该是比较切实可行的方案,它可以完全杜绝上面的事情发生,不过在编码的时候看起来有些别扭。
现在来分析一下第三种方案的逻辑:
处理机有两种状态,一种是忙碌,一种是空闲。
用户触发路由的时候,如果空闲则将其入队然后立即出队处理 URL,否则将 URL 入队,然后轮询数据库。
出队一次完成一次音频解析,然后看看队伍情况,如果为空转为空闲态,否则重新执行这一步。
考虑到以上三点的编码为:
代码长 已折叠
00const express = require('express') 01 , router = express.Router() 02 , rps = require('../tools/responser') 03 , ID3Reader = require('../tools/id3/reader') 04 , { mp3Model } = require('../tools/db') 05 , wait = require('../tools/wait') 06 07// 队列 08let Q = []; 09// 是否忙 10let pending = false; 11 12function store(req, res){ 13 let src; 14 15 if (Q.length !== 0) { 16 // 队列非空 17 if (!pending){ 18 // 不忙 出队到 src 并设置为忙 19 src = Q.pop(); 20 pending = true; 21 22 // 走一遍 toRead 23 return toRead(src, req, res).then(suc => { 24 // 成功的时候,再走一次 store 25 return store(req, res); 26 }); 27 } else { 28 // 忙 等待 100 毫秒后递归执行本函数 29 return wait(100).then(suc => { 30 return store(req, res); 31 }) 32 } 33 } else { 34 // 队列空 直接 resolved 并设置为 pending 35 pending = false; 36 return Promise.resolved(true); 37 } 38} 39 40// 读取 src 41function toRead(src, req, res){ 42 // 读取并缓存 并设置 pending 43 console.log('toRead', src); 44 45 return ID3Reader(src).then(mp3 => { 46 // Set Who 47 mp3.who = req.user_id; 48 49 // New Data And Save It 50 let data = new mp3Model(mp3); 51 52 return data.save(); 53 }).then(mp3 => { 54 rps.send2000(res, mp3); 55 }).catch(err => { 56 rps.send5006(res, {}, err.toString()); 57 }) 58} 59 60function reload(query){ 61 return wait(100).then(suc => { 62 return mp3Model.findOne(query).then(mp3 => { 63 if (mp3){ 64 return mp3; 65 } else { 66 return reload(query); 67 } 68 }) 69 }) 70} 71 72router.post('/', function(req, res){ 73 // 入队 74 Q.unshift(req.body.src); 75 76 if (!pending) { 77 // 空闲才做事 78 console.log('处理中 ... '); 79 store(req, res); 80 } else { 81 // 否则 轮询 82 reload({ 83 url: req.body.src 84 }).then(mp3 => { 85 rps.send2000(res, mp3); 86 }) 87 } 88});
# 前端部分 ↵
截图

musicList 可以从左滑到右边

music 音频播放
谈一些有趣的地方
# 添加音乐页的背景
musicList 里面添加音乐那里的背景 一张图宽 250rpx 高 250rpx , background 全部属性均使用 JavaScript 动态求出:
00setSty(){ 01 let a = sys.width_px / 3; 02 03 // 将 list 重复多次以填充屏幕 04 let repeat = this.data.list.concat(this.data.list).concat(this.data.list).concat(this.data.list).concat(this.data.list).concat(this.data.list); 05 // url 06 let urls = repeat.reduce((acc, item) => { 07 let img = item.picture; 08 return acc + `url(${img}),`; 09 }, 'background-image: ').slice(0, -1) + ';'; 10 11 // position-x 12 let posix = repeat.reduce((acc, item, idx) => { 13 let r = idx % 3; 14 return acc + `${r * a}px,`; 15 }, 'background-position-x: ').slice(0, -1) + ';'; 16 17 // position-y 18 let posiy = repeat.reduce((acc, item, idx) => { 19 let r = parseInt(idx / 3); 20 return acc + `${r * a}px,`; 21 }, 'background-position-y: ').slice(0, -1) + ';'; 22 23 // 加起来 24 this.setData({ 25 allUrlSty: urls + posix + posiy 26 }) 27}
# 后台播放
音乐是后台播放的。因此可以在主屏幕那边看到音乐,如图:

后台播放
应该是调用了苹果提供的原生的 API 进行播放,因此才有这些效果。
# Promise 递归链
当播放音乐的一瞬间,
wx.getBackgroundAudioPlayerState
是无效的,在 wx.playMusic 之后的一瞬间的时候调用它是不行的,因为 play 的一瞬间还需要从网络上下载音乐 需要一定时间
因此调用 getBackgroundAudioPlayerState 会直接失败 ( 即 Rejected ),而且,据我观察,即使成功了,返回的 state 里面不一定会有 duration,而还要再等等才会有 duration 。那么所谓的
一定时间
又是多少呢?不知道,这时候只能利用 Promise 进行递归轮询了。
00function getMusicInfo(){ 01 // wait 等待 200 毫秒后执行 _.getBackgroundAudioPlayerState() 02 return _.wait(200).then($$$$ => { 03 return _.getBackgroundAudioPlayerState(); 04 }).then(state => { 05 if (state.duration){ 06 // 如果 state 有 duration 字段 07 // 则转入 resolved 状态 08 state.duration_min = this.sec2min(state.duration); 09 return state; 10 } else { 11 // 还是不行 递归自己 12 return this.getMusicInfo() 13 } 14 }, err => { 15 // 失败了,递归自己 16 console.log('Get Music Info 失败', err); 17 return this.getMusicInfo() 18 }) 19}
调用的时候就很爽了。。 反正这条链将会是 Resolved 的 Promise 实例:
00this.getMusicInfo().then(state => { 01 // do some thing ... 02});
# 全栈开发的出路 ↵
随着对 Nightive 的深入开发,我越来越感觉,前后端都要懂的意义了。
不要求学的面面俱到、秒天秒地,但基本的数据库查询,http 生命周期这些总该有个认识,不然前端这边出的 bug 难道要后台帮你调?
如果前端不学学后台,很难对 HTTP,数据库,异步争用这些问题有深刻的理解;也不知道开发中经常遇到的所谓
token
是什么?还有如何生成它?还有其背后的密码学原理等等,又谈何理解前端安全?反过来,如果后台不学学前端,可能出的接口牛马不相及,不清楚前端渲染的需求,也难以理解前端工程化的意义,这样最终的结果就是经常性地鄙视前端、鄙视前端技术…
唯有把前后端的知识通串起来才算是一个合格的 Web 工程师,也只有这样才具备独立开发复杂网站的能力。
毫不夸张地说,全栈开发将很可能是很多前端认为的最终宿命。