2017-09-23
MP3
ID3
关于音乐播放器的前后端实现的思考
自打编程以来,经常性的一个活动就是做音频播放器,以前用 C 做的时候很痛苦,需要管理很多内存和数据结构,也没有图形,后来稍微学了点前端之后就开始做网页版的,收获很多,也做了梦寐以求的时域和频域的音频波谱图,之后学 Java 的时候也做了一个当作课程设计交了上去,最近我琢磨着把播放器搞到小程序上面去。

# 方案分析

小程序在音频上的处理并不是很强大,没有提供 Audio 对象做各种音频二进制数据的处理,只提供了一些简单操作,比如暂停,快进之类。
此外,小程序有两种播放音乐的方式,一种是利用 <audio> 标签,一种是利用微信背景音乐这类接口。
一开始我使用了 audio 标签来做,后来我发现,audio 那个并没有提供获取音频时长的 API 可以用,而背景音乐的 wx.getBackgroundAudioPlayerState 可以获取音频的一些元数据。
而且,也没有办法在前端获取到音频的专辑封面,因此这方面的工作只能交由后台来做。
MP3 的音频元数据标准规范是 ID1/2/3, 现在通常我们看到的 MP3 文件都是 ID3 或者 ID3V2 的,我在 NPM 上寻找了一番,最后选择了 npm - jsmediatags 用于解析 ID3 元数据, 原因如下:
  1. 支持解析 URL 形式的 MP3 文件 主要是这点
  2. 它是 JavaScript-ID3-Reader 的下一代,我之前做的播放器用的就是这个,完全继承了语法,因此看起来不会蛋疼
可能有人会问我为什么不在小程序上用 JavaScript-ID3-Reader ?原因很简单,它是基于 XHR 的,小程序上屏蔽了 XHR 因此无法使用。

最后我拟定的方案是:
  1. 前端用户输入音频 URL
  2. 服务器处理它,得到专辑封面
  3. 将封面传到七牛静态存储上
  4. 最后把数据保存在 Mongo 里
  5. 而且做好缓存,对于同一个 URL 的音频,将首先从数据库中调用,而不是重新处理一遍上面的流程

# 路由设计

拟定路由两个:
  1. GET /api/mp3
  2. POST /api/mp3
第一个用于获取服务器上保存的音乐,第二个则要求用户输入 src 然后服务器处理后续。
前端需要两个页面:
  1. /pages/musicList/musicList
  2. /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 并返回相关数据,这里面有很多异步流程,分别是:
  1. 查询 src 是否在表 mp3 里面,如果有,则直接返回结果,否则走下一步
  2. 利用 jsmediatags 读取 ID3 文件头, 得到文件头对象 id3
  3. id3 里面抽出图片数据(以数组的形式),并转为 Buffer 最后存储到磁盘上得到该图片路径路径 picLocal, 在此期间,利用 uuid 生成该图片的名字 picName
  4. 将图片上传到七牛得到图片远程链接 img
  5. 拼接以上过程中的数据得到形如下面的这个对象并返回:
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 秒,则会引发重传,而重传的时候由于数据并未生成缓存,因此会出现一次提交变出好多音频出来,这样显然不行。
解决以上的问题有很多解决方案:
  1. 取消前端超时重传机制
  2. 数据入库前先检查看看有没有同名的再入库
  3. 每次发来的 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 可以从左滑到右边
musicList 可以从左滑到右边
music 音频播放
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 工程师,也只有这样才具备独立开发复杂网站的能力。
毫不夸张地说,全栈开发将很可能是很多前端认为的最终宿命。




回到顶部